# VelvetKit > Accessible ViewComponent UI components for Rails, styled with Tailwind CSS 4 > and wired up with Stimulus. A generator copies the components an app asks > for, and the ones they depend on, into the app under a namespace it > chooses. After that they are the app's own code. ## Setting up an app Requirements: Rails 7.1+, Ruby 3.2+, Tailwind CSS 4, and a JavaScript bundler that compiles TypeScript (Bun or esbuild through jsbundling-rails). The Stimulus controllers are `.ts` files, so importmap alone won't do. ```ruby # Gemfile gem "velvetkit", group: :development ``` ```bash bundle install bin/rails generate velvetkit:install --namespace=UI # or any module name: Kit, Admin… bin/rails generate velvetkit:add dialog date_picker # these, plus what they use bin/rails generate velvetkit:add # list every component ``` `install` writes `config/velvetkit.yml` (the namespace), adds `view_component` and `tailwind_merge` to the Gemfile, copies the design tokens (`app/assets/tailwind/velvetkit.css`), translations, Lucide icon setup and the base component, and writes `docs/velvetkit.md`: a reference for AI agents covering the components installed in that app, in its namespace. A Claude Code skill and an AGENTS.md section point agents at it, and `add` keeps it current. Generator options: `--namespace=Kit`, `--all`, `--skip-tests`, `--skip-previews`, `--skip-packages`, `--skip-ai`, `--force`, `--skip`. ## Namespacing Components are written against `UI` and rewritten as they are copied. With `--namespace=Kit`: `UI::Button` becomes `Kit::Button`, `app/components/ui/` becomes `app/components/kit/`, Stimulus `ui--menu` becomes `kit--menu`, and I18n `ui.dialog.close_label` becomes `kit.dialog.close_label`. All-capital names such as `UI` need `inflect.acronym "UI"`, which `install` adds. ## Theming Components use colour tokens (`bg-primary`, `text-muted-foreground`, `border-input`), defined in `app/assets/tailwind/velvetkit.css` once for light and once under `.dark`. Retheme by changing the token values, never the components. Dark mode is the `dark` class on ``; the `theme_toggle` component switches it, and its `layouts/theme_script` partial, rendered in ``, prevents a flash of the light theme. ## Lookbook Every component comes with Lookbook previews in `test/components/previews//`. Add `gem "lookbook"` to the development group and `mount Lookbook::Engine, at: "/lookbook"` in `config/routes.rb` to browse them. # VelvetKit components in this app This app builds its UI from VelvetKit components: ViewComponents styled with Tailwind CSS 4 and wired up with Stimulus. They were copied into the app by the `velvetkit` gem and are now the app's own code, under `UI::`. Use them for every new screen. Reach for raw HTML and Tailwind only when no component fits, and never build a second version of a component that exists. ## Where things are - Components: `app/components/ui/` (`UI::Button` is `app/components/ui/button.rb`) - Their Stimulus controllers: `app/javascript/controllers/ui/`, registered as `ui--` - Tests: `test/components/ui/` (Minitest, `render_inline`) - Lookbook previews: `test/components/previews/ui/` - Design tokens: `app/assets/tailwind/velvetkit.css` - Strings: `config/locales/velvetkit.en.yml`, under `ui.` - Icons: `app/javascript/velvetkit_icons.ts` ## Rules 1. **Render, with slots.** Components take keyword arguments and fill their parts through `with_*` slots in a block: ```erb <%= render(UI::Card.new) do |card| %> <% card.with_header do |header| %> <% header.with_title { "Billing" } %> <% end %> <% card.with_content { "…" } %> <% end %> ``` 2. **Pass `class:` to adjust, not to restyle.** Your classes are merged with the component's by tailwind_merge, and yours win on conflict. For a different look, use a `variant:` or `size:` the component already has. 3. **Never hardcode colours.** Use the tokens: `bg-primary`, `text-muted-foreground`, `border-input`, `bg-card`, `text-destructive`. They switch with dark mode. Hex values and `blue-600` don't. 4. **Need a component the app doesn't have yet?** Add it from the kit rather than writing one: `bin/rails generate velvetkit:add `. It copies the component with everything it depends on, and refreshes this file. Available: accordion, alert, avatar, avatar_group, badge, banner, breadcrumb, button, button_to, card, checkbox, collapsible, data_grid, date_picker, dialog, divider, form_field, icon, image, input, input_group, label, link, menu, pagination, password_input, popover, radio_group, native_select, select, sheet, sidebar, spinner, switch, table, table_of_contents, tabs, text, textarea, theme_toggle, tooltip, typography. 5. **Icons are Lucide names** (`UI::Icon.new(name: "calendar")`). An icon the app hasn't used before must be added to `app/javascript/velvetkit_icons.ts`, in both the import list and the `icons` object, or it renders empty. 6. **User-facing text goes through I18n**, as the components' own strings do. 7. **Changing a component is fine**, it's app code. Keep its test passing, and update its Lookbook preview so the change can be seen. 8. **Links that look like buttons** use `UI::Link.new(href:, variant:, button_variant: true)`. Without `button_variant: true` a link is styled as text. ## Components installed here Each section below lists a component's arguments, slots and working examples. The examples are the same templates its Lookbook previews render. ## Accordion (`UI::Accordion`) Sections that expand and collapse, one at a time or several. Add it: `bin/rails generate velvetkit:add accordion` Also adds: `icon` Stimulus: `ui--accordion` ### Arguments ```ruby UI::Accordion.new(type: :single, collapsible: true, default_value: nil, class: nil, **html_attrs) ``` Slots: `with_item`, `with_trigger`, `with_accordion_content`, `with_content` ### Example: Single (one open at a time) ```erb
<%= render(UI::Accordion.new(type: :single, default_value: "item-1")) do |accordion| %> <% accordion.with_item(value: "item-1") do |item| %> <% item.with_trigger { "What is a workspace?" } %> <% item.with_content do %>

A workspace holds your team, projects and billing in one place. Everyone you invite sees the same projects.

<% end %> <% end %> <% accordion.with_item(value: "item-2") do |item| %> <% item.with_trigger { "How do I get started?" } %> <% item.with_content do %>

Create an account, name your workspace and invite your team. A short setup guide walks you through each step.

<% end %> <% end %> <% accordion.with_item(value: "item-3") do |item| %> <% item.with_trigger { "Is my data secure?" } %> <% item.with_content do %>

Yes. We use industry-standard encryption and follow GDPR to ensure your data is protected.

<% end %> <% end %> <% end %>
``` ### Example: Multiple (independent items) ```erb
<%= render(UI::Accordion.new(type: :multiple, default_value: ["item-1", "item-3"])) do |accordion| %> <% accordion.with_item(value: "item-1") do |item| %> <% item.with_trigger { "Features" } %> <% item.with_content do %>
  • Projects and tasks
  • Time tracking
  • Grade reporting
  • Client portal
<% end %> <% end %> <% accordion.with_item(value: "item-2") do |item| %> <% item.with_trigger { "Pricing" } %> <% item.with_content do %>

We offer flexible pricing plans for teams of all sizes. Contact our sales team for a custom quote.

<% end %> <% end %> <% accordion.with_item(value: "item-3") do |item| %> <% item.with_trigger { "Support" } %> <% item.with_content do %>

24/7 support via email and chat. Premium plans include dedicated account managers and phone support.

<% end %> <% end %> <% end %>
``` ### Example: FAQ Style ```erb

Frequently Asked Questions

<%= render(UI::Accordion.new(type: :single)) do |accordion| %> <% faqs = [ { q: "How do I reset my password?", a: "Go to Settings > Security and click 'Change Password'. You'll need to enter your current password and your new password." }, { q: "Can I import data from another system?", a: "Yes, we import CSV and JSON files. Go to Admin > Data Import and follow the wizard to map your fields." }, { q: "What integrations are available?", a: "Most tools connect through our API and webhooks, and the integrations page lists ready-made ones." }, { q: "Is there a mobile app?", a: "Yes, for iOS and Android. Search for it in your phone's app store." }, { q: "How do I contact support?", a: 'Email us at support@example.com or use the live chat widget in the bottom-right corner of your dashboard.' } ] %> <% faqs.each_with_index do |faq, i| %> <% accordion.with_item(value: "faq-#{i}") do |item| %> <% item.with_trigger { faq[:q] } %> <% item.with_content do %>

<%= faq[:a].html_safe %>

<% end %> <% end %> <% end %> <% end %>
``` ## Alert (`UI::Alert`) A highlighted message, optionally dismissible. Add it: `bin/rails generate velvetkit:add alert` Also adds: `icon`, `spinner`, `button` Stimulus: `ui--alert` ### Arguments ```ruby UI::Alert.new(variant: :default, dismissible: false, class: nil, **html_attrs) ``` Slots: `with_icon`, `with_title`, `with_description` ### Example: All Variants ```erb
<%= render(UI::Alert.new) do |alert| %> <% alert.with_title { "Default" } %> <% alert.with_description { "This is a default alert with important information." } %> <% end %> <%= render(UI::Alert.new(variant: :success)) do |alert| %> <% alert.with_title { "Success" } %> <% alert.with_description { "Your changes have been saved successfully." } %> <% end %> <%= render(UI::Alert.new(variant: :destructive)) do |alert| %> <% alert.with_title { "Destructive" } %> <% alert.with_description { "There was a problem with your request." } %> <% end %> <%= render(UI::Alert.new(variant: :warning)) do |alert| %> <% alert.with_title { "Warning" } %> <% alert.with_description { "This action cannot be undone." } %> <% end %> <%= render(UI::Alert.new(variant: :info)) do |alert| %> <% alert.with_title { "Info" } %> <% alert.with_description { "Here's some helpful information." } %> <% end %>
``` ### Example: With Icon ```erb
<%= render(UI::Alert.new) do |alert| %> <% alert.with_icon(name: "badge-check") %> <% alert.with_title { "Custom icon" } %> <% alert.with_description { "Uses a custom badge-check icon instead of default." } %> <% end %> <%= render(UI::Alert.new(variant: :success)) do |alert| %> <% alert.with_icon(name: "circle-check", class: "text-emerald-600") %> <% alert.with_title { "Custom colored icon" } %> <% alert.with_description { "Icon color overridden via class prop." } %> <% end %>
``` ### Example: Title Only ```erb
<%= render(UI::Alert.new) do |alert| %> <% alert.with_title { "Title only, no description" } %> <% end %> <%= render(UI::Alert.new(variant: :info)) do |alert| %> <% alert.with_title { "Info with custom icon" } %> <% end %> <%= render(UI::Alert.new(variant: :warning)) do |alert| %> <% alert.with_icon(name: "circle-alert") %> <% alert.with_title { "Warning with custom icon, title only" } %> <% end %>
``` ### Example: With Link ```erb
<%= render(UI::Alert.new(variant: :info)) do |alert| %> <% alert.with_title { "Did you know?" } %> <% alert.with_description { "Check out the documentation for more details.".html_safe } %> <% end %> <%= render(UI::Alert.new(variant: :success)) do |alert| %> <% alert.with_title { "Profile updated" } %> <% alert.with_description { "Your changes were saved. View your profile to confirm.".html_safe } %> <% end %> <%= render(UI::Alert.new(variant: :warning)) do |alert| %> <% alert.with_title { "Action required" } %> <% alert.with_description { "Please verify your email before continuing.".html_safe } %> <% end %> <%= render(UI::Alert.new(variant: :destructive)) do |alert| %> <% alert.with_title { "Payment failed" } %> <% alert.with_description { "Your card was declined. Update payment method to retry.".html_safe } %> <% end %>
``` ## Avatar (`UI::Avatar`) A person's picture, with initials when there is none. Add it: `bin/rails generate velvetkit:add avatar` ### Arguments ```ruby UI::Avatar.new(shape: :circle, size: :default, color: :default, alt: nil, class: nil, **html_attrs) ``` Slots: `with_image`, `with_fallback` ### Example: Sizes ```erb
<% { sm: 24, default: 40, lg: 48, xl: 64 }.each do |size, px| %>
<%= render(UI::Avatar.new(size: size)) { "JD" } %> <%= size %> (<%= px %>px)
<% end %>
``` ### Example: Shapes ```erb
<%= render(UI::Avatar.new(shape: :circle)) { "JD" } %> Circle (People)
<%= render(UI::Avatar.new(shape: :rounded)) { "NS" } %> Rounded (Groups/Projects)
``` ### Example: Colors ```erb
<% { default: "bg-muted", primary: "bg-primary", destructive: "bg-destructive", amber: "bg-amber-500", emerald: "bg-emerald-500", purple: "bg-purple-500", sky: "bg-sky-500" }.each do |color, label| %>
<%= render(UI::Avatar.new(color: color)) { "JD" } %> <%= color %>
<% end %>
``` ### Example: With Image ```erb
<% { sm: 24, default: 40, lg: 48 }.each do |size, px| %>
<%= render(UI::Avatar.new(size: size)) do |avatar| %> <% avatar.with_image(src: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&fit=crop&crop=face", alt: "User") %> <% end %> <%= size %> (<%= px %>px)
<% end %>
``` ## Avatar group (`UI::AvatarGroup`) Overlapping avatars with a "+N" count for the rest. Add it: `bin/rails generate velvetkit:add avatar_group` Also adds: `avatar` ### Arguments ```ruby UI::AvatarGroup.new(max: 5, size: :default, class: nil, **html_attrs) ``` Slots: `with_avatar` ### Example: Default Stack ```erb

4 avatars, overlapping with ring separation

<%= render(UI::AvatarGroup.new) do |group| %> <% group.with_avatar { "JD" } %> <% group.with_avatar { "AS" } %> <% group.with_avatar { "RM" } %> <% group.with_avatar { "BK" } %> <% end %>
``` ### Example: With Overflow ```erb

max=3 with 6 total → shows 3 + "+3"

<%= render(UI::AvatarGroup.new(max: 3)) do |group| %> <% group.with_avatar { "JD" } %> <% group.with_avatar { "AS" } %> <% group.with_avatar { "RM" } %> <% group.with_avatar { "BK" } %> <% group.with_avatar { "CD" } %> <% group.with_avatar { "PK" } %> <% end %>

max=2 with 5 total → shows 2 + "+3"

<%= render(UI::AvatarGroup.new(max: 2, size: :sm)) do |group| %> <% group.with_avatar { "JD" } %> <% group.with_avatar { "AS" } %> <% group.with_avatar { "RM" } %> <% group.with_avatar { "BK" } %> <% group.with_avatar { "CD" } %> <% end %>
``` ### Example: Small Stack ```erb

Small avatars stacked (size: :sm)

<%= render(UI::AvatarGroup.new(size: :sm)) do |group| %> <% group.with_avatar { "JD" } %> <% group.with_avatar { "AS" } %> <% group.with_avatar { "RM" } %> <% group.with_avatar { "BK" } %> <% group.with_avatar { "CD" } %> <% end %>
``` ## Badge (`UI::Badge`) A small status label. Add it: `bin/rails generate velvetkit:add badge` Also adds: `icon` ### Arguments ```ruby UI::Badge.new(variant: :default, size: :default, class: nil, **html_attrs) ``` Slots: `with_leading_icon` ### Example: All Variants ```erb
<%= render(UI::Badge.new(variant: :default)) { "Default" } %> <%= render(UI::Badge.new(variant: :secondary)) { "Secondary" } %> <%= render(UI::Badge.new(variant: :destructive)) { "Destructive" } %> <%= render(UI::Badge.new(variant: :outline)) { "Outline" } %> <%= render(UI::Badge.new(variant: :success)) { "Success" } %> <%= render(UI::Badge.new(variant: :warning)) { "Warning" } %>
``` ### Example: Sizes ```erb
<%= render(UI::Badge.new(size: :sm)) { "Small" } %> <%= render(UI::Badge.new(size: :default)) { "Default" } %> <%= render(UI::Badge.new(size: :lg)) { "Large" } %>
``` ### Example: With Icon ```erb
<%= render(UI::Badge.new) do |badge| %> <% badge.with_leading_icon(name: "badge-check") %> Verified <% end %> <%= render(UI::Badge.new(variant: :success)) do |badge| %> <% badge.with_leading_icon(name: "circle-check") %> 42 <% end %> <%= render(UI::Badge.new(variant: :warning)) do |badge| %> <% badge.with_leading_icon(name: "circle-alert") %> Pending <% end %>
``` ## Banner (`UI::Banner`) A dismissible strip across the top of a page. Add it: `bin/rails generate velvetkit:add banner` Also adds: `icon`, `spinner`, `button` Stimulus: `ui--banner` ### Arguments ```ruby UI::Banner.new(variant: :introduction, dismissible: true, full_width: false, dismiss_icon: "x", class: nil, **html_attrs) ``` Slots: `with_illustration`, `with_leading_icon`, `with_title`, `with_action` ### Example: Introduction ```erb
<%= render(UI::Banner.new(variant: :introduction)) do |banner| %> <% banner.with_title { "Welcome to your workspace" } %> Invite your team, connect your data and publish your first report. <% banner.with_action do %> Get started → <% end %> <% end %>
``` ### Example: Promotion ```erb
<%= render(UI::Banner.new(variant: :promotion)) do |banner| %> <% banner.with_title { "Now available: Advanced Analytics" } %> Unlock powerful insights with our new analytics dashboard. Free for Pro plan users until June 30. <% banner.with_action do %> Upgrade now → <% end %> <% end %>
``` ### Example: With Illustration ```erb
<%= render(UI::Banner.new(variant: :introduction)) do |banner| %> <% banner.with_illustration do %> <% end %> <% banner.with_title { "Introducing Velvet Kit" } %> A modern component library for Rails. Build beautiful interfaces faster with pre-built, accessible components. <% banner.with_action do %> View docs → <% end %> <% end %>
``` ## Breadcrumb (`UI::Breadcrumb`) The path to the current page, collapsing long trails. Add it: `bin/rails generate velvetkit:add breadcrumb` Also adds: `avatar`, `icon`, `link`, `spinner`, `button`, `popover` ### Arguments ```ruby UI::Breadcrumb.new(size: :sm, separator_icon: :slash, max_items: nil, collapse_at: :middle, class: nil, **html_attrs) ``` Slots: `with_item`, `with_ellipsis` ### Example: Default (sm) ```erb
<%= render(UI::Breadcrumb.new(size: :sm)) do |bc| %> <% bc.with_item(href: "#", text: "Workspace") %> <% bc.with_item(href: "#", text: "Website") %> <% bc.with_item(href: "#", text: "Issues") %> <% bc.with_item(href: "#", text: "#1234") %> <% end %>
``` ### Example: Medium (md) ```erb
<%= render(UI::Breadcrumb.new(size: :md)) do |bc| %> <% bc.with_item(href: "#", text: "Workspace") %> <% bc.with_item(href: "#", text: "Website") %> <% bc.with_item(href: "#", text: "Issues") %> <% end %>
``` ### Example: With Avatar ```erb
<%= render(UI::Breadcrumb.new(size: :sm)) do |bc| %> <% bc.with_item(href: "#", text: "Website", avatar_src: "/icon.png") %> <% bc.with_item(href: "#", text: "Projects", avatar_src: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT9Y9w-vhwNHxfSOZWSDFJ-9iPU8EPY0GR47A&s") %> <% bc.with_item(href: "#", text: "Dashboard") %> <% bc.with_item(href: "#", text: "Settings") %> <% end %>
``` ### Example: Long Path ```erb
<%= render(UI::Breadcrumb.new(size: :sm)) do |bc| %> <% bc.with_item(href: "#", text: "Workspace") %> <% bc.with_item(href: "#", text: "Very Long Project Name That Should Truncate") %> <% bc.with_item(href: "#", text: "Settings") %> <% bc.with_item(href: "#", text: "General") %> <% end %>
``` ### Example: Collapsed (legacy explicit ellipsis) ```erb

Slot API — ellipsis at end

<%= render(UI::Breadcrumb.new(size: :sm)) do |bc| %> <% bc.with_item(href: "#", text: "Projects") %> <% bc.with_item(href: "#", text: "Website", avatar_src: "/icon.png") %> <% bc.with_ellipsis do %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Settings" } %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Members" } %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Billing" } %> <% end %> <% bc.with_item(href: "#", text: "Dashboard") %> <% end %>

Compound API — ellipsis in middle

<%= render(UI::Breadcrumb.new(size: :sm)) do %> <%= render(UI::Breadcrumb::List.new) do %> <%= render(UI::Breadcrumb::Item.new) do %> <%= render(UI::Link.new(href: "#", class: "text-xs text-muted-foreground max-w-32 truncate font-normal justify-start rounded-sm")) { "Handbook" } %> <% end %> <%= render(UI::Breadcrumb::Separator.new) %> <%= render(UI::Breadcrumb::Ellipsis.new) do %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Docs" } %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "API" } %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Platform" } %> <% end %> <%= render(UI::Breadcrumb::Separator.new) %> <%= render(UI::Breadcrumb::Item.new) do %> <%= render(UI::Link.new(href: "#", class: "text-xs text-foreground font-normal justify-start rounded-sm", aria: { current: "page" })) { "Cloud providers" } %> <% end %> <% end %> <% end %>

Compound API — ellipsis at start

<%= render(UI::Breadcrumb.new(size: :sm)) do %> <%= render(UI::Breadcrumb::List.new) do %> <%= render(UI::Breadcrumb::Ellipsis.new) do %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Learn" } %> <%= render(UI::Link.new(href: "#", class: "justify-start rounded-sm px-3 py-1.5 text-sm font-normal text-popover-foreground hover:bg-accent hover:text-accent-foreground hover:no-underline w-full")) { "Tutorials" } %> <% end %> <%= render(UI::Breadcrumb::Separator.new) %> <%= render(UI::Breadcrumb::Item.new) do %> <%= render(UI::Link.new(href: "#", class: "text-xs text-foreground font-normal justify-start rounded-sm", aria: { current: "page" })) { "Getting Started" } %> <% end %> <% end %> <% end %>
``` ## Button (`UI::Button`) Buttons in every variant and size, with icons and a loading state. Add it: `bin/rails generate velvetkit:add button` Also adds: `icon`, `spinner` ### Arguments ```ruby UI::Button.new(variant: :default, size: :default, type: :button, full_width: false, loading: false, disabled: false, as_child: false, class: nil, **html_attrs) ``` Slots: `with_leading_icon`, `with_trailing_icon` ### Example: All Variants ```erb
<%= render(UI::Button.new(variant: :default)) { "Default" } %> <%= render(UI::Button.new(variant: :destructive)) { "Destructive" } %> <%= render(UI::Button.new(variant: :outline)) { "Outline" } %> <%= render(UI::Button.new(variant: :secondary)) { "Secondary" } %> <%= render(UI::Button.new(variant: :ghost)) { "Ghost" } %> <%= render(UI::Button.new(variant: :link)) { "Link" } %>
``` ### Example: All Sizes ```erb
<%= render(UI::Button.new(size: :sm)) { "Small" } %> <%= render(UI::Button.new(size: :default)) { "Default" } %> <%= render(UI::Button.new(size: :lg)) { "Large" } %> <%= render(UI::Button.new(size: :icon)) { "I" } %>
``` ### Example: With Leading Icon ```erb
<%= render(UI::Button.new(variant: :default)) do |btn| btn.with_leading_icon(name: "lock") "Sign in" end %> <%= render(UI::Button.new(variant: :outline)) do |btn| btn.with_leading_icon(name: "eye") "Preview" end %> <%= render(UI::Button.new(variant: :secondary)) do |btn| btn.with_leading_icon(name: "badge-check") "Verified" end %> <%= render(UI::Button.new(variant: :destructive)) do |btn| btn.with_leading_icon(name: "x") "Delete" end %> <%= render(UI::Button.new(variant: :ghost)) do |btn| btn.with_leading_icon(name: "user") "Profile" end %>
``` ### Example: With Trailing Icon ```erb
<%= render(UI::Button.new(variant: :default)) do |btn| btn.with_trailing_icon(name: "arrow-right") "Continue" end %> <%= render(UI::Button.new(variant: :outline)) do |btn| btn.with_trailing_icon(name: "chevrons-up-down") "Sort" end %> <%= render(UI::Button.new(variant: :link)) do |btn| btn.with_trailing_icon(name: "arrow-right") "Read more" end %>
``` ### Example: With Both Icons ```erb
<%= render(UI::Button.new(variant: :default)) do |btn| btn.with_leading_icon(name: "lock") btn.with_trailing_icon(name: "arrow-right") "Sign in" end %> <%= render(UI::Button.new(variant: :outline, size: :lg)) do |btn| btn.with_leading_icon(name: "eye") btn.with_trailing_icon(name: "chevrons-up-down") "Preview options" end %>
``` ### Example: Icon Only ```erb
<%= render(UI::Button.new(variant: :default, size: :icon)) do |btn| btn.with_leading_icon(name: "menu") end %> <%= render(UI::Button.new(variant: :outline, size: :icon)) do |btn| btn.with_leading_icon(name: "user") end %> <%= render(UI::Button.new(variant: :secondary, size: :icon)) do |btn| btn.with_leading_icon(name: "panel-left") end %> <%= render(UI::Button.new(variant: :ghost, size: :icon)) do |btn| btn.with_leading_icon(name: "x") end %> <%= render(UI::Button.new(variant: :destructive, size: :icon)) do |btn| btn.with_leading_icon(name: "x") end %>
``` ### Example: Loading ```erb
<%= render(UI::Button.new(loading: true)) { "Saving" } %> <%= render(UI::Button.new(variant: :outline, loading: true)) do |btn| btn.with_leading_icon(name: "lock") "Authenticating" end %> <%= render(UI::Button.new(variant: :destructive, loading: true)) do |btn| btn.with_trailing_icon(name: "arrow-right") "Deleting" end %> <%= render(UI::Button.new(variant: :default, size: :icon, loading: true)) %>
``` ### Example: Disabled ```erb

All variants — disabled

<%= render(UI::Button.new(variant: :default, disabled: true)) { "Default" } %> <%= render(UI::Button.new(variant: :destructive, disabled: true)) { "Destructive" } %> <%= render(UI::Button.new(variant: :outline, disabled: true)) { "Outline" } %> <%= render(UI::Button.new(variant: :secondary, disabled: true)) { "Secondary" } %> <%= render(UI::Button.new(variant: :ghost, disabled: true)) { "Ghost" } %> <%= render(UI::Button.new(variant: :link, disabled: true)) { "Link" } %>

Disabled with icons

<%= render(UI::Button.new(disabled: true)) do |btn| btn.with_leading_icon(name: "lock") "Locked" end %> <%= render(UI::Button.new(variant: :outline, disabled: true)) do |btn| btn.with_trailing_icon(name: "arrow-right") "Continue" end %> <%= render(UI::Button.new(variant: :destructive, size: :icon, disabled: true)) do |btn| btn.with_leading_icon(name: "x") end %>

Loading auto-disables (cursor-not-allowed shows on hover)

<%= render(UI::Button.new(loading: true)) { "Saving" } %> <%= render(UI::Button.new(variant: :destructive, loading: true)) { "Deleting" } %> <%= render(UI::Button.new(variant: :outline, loading: true)) { "Loading" } %>
``` ## Button to (`UI::ButtonTo`) A button that submits a one-off form, like Rails' button_to. Add it: `bin/rails generate velvetkit:add button_to` Also adds: `icon`, `spinner`, `button` ### Arguments ```ruby UI::ButtonTo.new(path, form_class: nil, **args) ``` ### Notes UI::ButtonTo — a UI::Button rendered inside a real form via Rails' `button_to`. Use when the action is a non-GET request (sign-in with a provider, destroy, any state change) rather than navigation. Inherits UI::Button's variants, sizes and icon slots so the two stay visually identical. Velvet::ButtonTo inherited Button's icon/loading slots but its template never rendered them; this one does. Usage: <%= render(UI::ButtonTo.new(sign_out_path, method: :delete, variant: :outline)) do %> Sign out <% end %> ### Example: Variants ```erb
<% %i[default destructive outline secondary ghost link].each do |v| %> <%= render(UI::ButtonTo.new("#", variant: v)) { v.to_s.capitalize } %> <% end %>
``` ### Example: With Icon ```erb <%# Icon slots render here, unlike the Velvet component this replaces. %>
<%= render(UI::ButtonTo.new("#", variant: :outline)) do |button| %> <% button.with_leading_icon(name: "mail") %> Sign in with email <% end %> <%= render(UI::ButtonTo.new("#")) do |button| %> <% button.with_trailing_icon(name: "arrow-right") %> Continue <% end %>
``` ### Example: States ```erb
<%= render(UI::ButtonTo.new("#")) { "Default" } %> <%= render(UI::ButtonTo.new("#", loading: true)) { "Loading" } %> <%= render(UI::ButtonTo.new("#", disabled: true)) { "Disabled" } %>
<%= render(UI::ButtonTo.new("#", variant: :outline, full_width: true, form_class: "w-full")) { "Full width" } %>
``` ## Card (`UI::Card`) A bordered container with header, content and footer. Add it: `bin/rails generate velvetkit:add card` ### Arguments ```ruby UI::Card.new(class: nil, **html_attrs) ``` Slots: `with_header`, `with_card_content`, `with_footer`, `with_content`, `with_title`, `with_description` ### Example: Default (content only) ```erb
<%= render(UI::Card.new) do |card| %> <% card.with_content do %>

A simple card with only body content.

<% end %> <% end %>
``` ### Example: With Header ```erb
<%= render(UI::Card.new) do |card| %> <% card.with_header do |header| %> <% header.with_title { "Notifications" } %> <% header.with_description { "Configure how you receive notifications." } %> <% end %> <% card.with_content do %>

Card with a header containing a title and description.

<% end %> <% end %>
``` ### Example: With Footer ```erb
<%= render(UI::Card.new) do |card| %> <% card.with_content do %>

Card with body content and a footer containing actions.

<% end %> <% card.with_footer do %>
<%= render(UI::Button.new(variant: :primary, size: :sm)) { "Save" } %> <%= render(UI::Button.new(variant: :outline, size: :sm)) { "Cancel" } %>
<% end %> <% end %>
``` ### Example: Complete (all sections) ```erb
<%= render(UI::Card.new) do |card| %> <% card.with_header do |header| %> <% header.with_title { "Team Members" } %> <% header.with_description { "Manage your team and permissions." } %> <% end %> <% card.with_content do %>
JD

John Doe

Admin

AS

Alice Smith

Editor

<% end %> <% card.with_footer do %>
<%= render(UI::Button.new(variant: :outline, size: :sm, class: "w-full")) { "Invite Member" } %>
<% end %> <% end %>
``` ### Example: Settings Form ```erb
<%= render(UI::Card.new) do |card| %> <% card.with_header do |header| %> <% header.with_title { "Account Settings" } %> <% header.with_description { "Update your account preferences." } %> <% end %> <% card.with_content do %>
<%= render(UI::Label.new(for: "name")) { "Display Name" } %> <%= render(UI::Input.new(id: "name", placeholder: "Enter your name")) %>
<%= render(UI::Label.new(for: "username")) { "Username" } %> <%= render(UI::Input.new(id: "username", placeholder: "@username")) %>
<% end %> <% card.with_footer do %>
<%= render(UI::Button.new(variant: :primary, size: :sm)) { "Save Changes" } %>
<% end %> <% end %>
``` ## Checkbox (`UI::Checkbox`) A checkbox with label, standalone or bound to a form builder. Add it: `bin/rails generate velvetkit:add checkbox` Also adds: `label`, `form_field`, `icon` ### Arguments ```ruby UI::Checkbox.new(name: nil, value: "1", unchecked_value: "0", checked: false, label: nil, hint: nil, size: :default, form: nil, field: nil, required: false, error: nil, disabled: false, class: nil, **html_attrs) ``` ### Example: States ```erb
<%= render(UI::Checkbox.new(name: "unchecked")) %> Unchecked
<%= render(UI::Checkbox.new(name: "checked", checked: true)) %> Checked
<%= render(UI::Checkbox.new(name: "disabled", disabled: true)) %> Disabled
<%= render(UI::Checkbox.new(name: "disabled-checked", disabled: true, checked: true)) %> Disabled + Checked
<%= render(UI::Checkbox.new(name: "error", error: true)) %> Error
``` ### Example: With Label ```erb
<%= render(UI::Checkbox.new(name: "small", label: "Small checkbox", size: :sm)) %> <%= render(UI::Checkbox.new(name: "default", label: "Default checkbox", size: :default)) %> <%= render(UI::Checkbox.new(name: "large", label: "Large checkbox", size: :lg)) %>
``` ### Example: In Form ```erb
Notification preferences <%= render(UI::Checkbox.new(name: "prefs[email]", checked: true, label: "Email notifications")) %> <%= render(UI::Checkbox.new(name: "prefs[push]", label: "Push notifications")) %> <%= render(UI::Checkbox.new(name: "prefs[sms]", disabled: true, label: "SMS (unavailable)")) %>
<%= render(UI::Button.new(type: :submit)) { "Save preferences" } %>
``` ## Collapsible (`UI::Collapsible`) A section that opens and closes under its trigger, like a branch of a nav tree. Add it: `bin/rails generate velvetkit:add collapsible` Also adds: `icon` ### Arguments ```ruby UI::Collapsible.new(open: false, size: :default, trigger_class: nil, content_class: nil, class: nil, **html_attrs) ``` Slots: `with_trigger` ### Notes A section that opens and closes under its trigger: a tree of links in a sidebar, "Show more" details, an advanced-settings block. Built on
and , so it works before any JavaScript loads, Enter and Space toggle it, screen readers announce it as expanded or collapsed, and find-in-page opens it to show a match. Browsers that can animate to auto height slide it open; the rest open it at once. <%= render(UI::Collapsible.new(open: current_page_is_a_form?)) do |c| %> <% c.with_trigger { "Forms" } %> <%= link_to "Input", input_path %> <% end %> Collapsibles nest, so a trigger inside the content makes a deeper branch. ### Example: Navigation tree ```erb <%# A sidebar tree: branches open on click, and the one holding the current page starts open. %> ``` ### Example: Show more ```erb

Payments are collected on the 1st of each month.

<%= render(UI::Collapsible.new(size: :sm, class: "mt-2", trigger_class: "w-auto -ml-2 text-primary hover:underline")) do |c| %> <% c.with_trigger { "How refunds work" } %>

Cancel within 14 days for a full refund. After that, the rest of the month is credited to your next invoice.

<% end %>
``` ## Data grid (`UI::DataGrid`) A sortable, filterable, server-paged table on AG Grid. Add it: `bin/rails generate velvetkit:add data_grid` Stimulus: `ui--data-grid` Installs: `ag-grid-community` ### Arguments ```ruby UI::DataGrid.new(url:, page_size: 15, empty_message: nil, class: nil, **html_attrs) ``` ### Notes UI::DataGrid — a sortable, filterable, server-paged table backed by AG Grid. The grid holds one page at a time and asks the server for the next, sending the sort and filter it wants applied, so paging, sorting and filtering all happen in the database. The server answers GET `url` with `startRow`, `endRow`, `sortModel` and `filterModel` (both JSON) as params, returning `{ "rows": [...], "total": n }`. Columns are declared in Ruby so the headers, their widths and which of them the server will accept a sort or filter for are decided in one place. AG Grid is ~365 KB gzipped, so the Stimulus controller fetches it at connect time: a page without a grid never downloads it. Usage: <%= render(UI::DataGrid.new(url: customers_path(format: :json)) do |grid| grid.column(:id, t(".id"), width: 90, type: :number) grid.column(:name, t(".name")) grid.column(:owner, t(".owner"), sortable: false, filterable: false) end) %> ## Date picker (`UI::DatePicker`) A typed date field with a calendar, on Cally. Add it: `bin/rails generate velvetkit:add date_picker` Also adds: `label`, `form_field`, `icon` Stimulus: `ui--date-picker` Installs: `cally` ### Arguments ```ruby UI::DatePicker.new(name: nil, value: nil, form: nil, field: nil, label: nil, required: false, error: nil, disabled: false, hint: true, min: nil, max: nil, class: nil, **html_attrs) ``` ### Notes UI::DatePicker — a typed date field with a calendar beside it. Three inputs in one: what the user types (DD/MM/YYYY), what the form submits (ISO, hidden), and the calendar. The typed field comes first because people who know the date type it in a second: reaching a date years away in a calendar means clicking back month after month. The written format is not decided here. It comes from `date.formats.typed` in the locale, and this component derives the placeholder, the parse order and the separator from that one string — so a locale that writes dates differently changes what the field accepts, what it shows and what the hint says, together, with nothing hardcoded. The shipped English format is day-first; change `date.formats.typed` for month-first. A native date input follows the browser's locale instead, which is how the same form reads MM/DD on one machine and DD/MM on another, and how the 2nd of May gets recorded as the 5th of February. Cally is ~9 KB gzipped and loaded only when the calendar is first opened, so a form nobody opens a calendar on downloads nothing. Usage: <%= render(UI::DatePicker.new(form: f, field: :starts_on, required: true)) %> <%= render(UI::DatePicker.new(name: "event[date]", value: @date, label: "Date")) %> ### Example: Default ```erb
<%= render(UI::DatePicker.new(name: "event[date]", label: "Date of birth", value: Date.new(2012, 5, 2))) %>
``` ### Example: States ```erb
<%= render(UI::DatePicker.new(name: "a", label: "Empty")) %> <%= render(UI::DatePicker.new(name: "b", label: "Required", required: true)) %> <%= render(UI::DatePicker.new(name: "c", label: "With an error", error: true, value: Date.new(2026, 10, 21))) %> <%= render(UI::DatePicker.new(name: "d", label: "Disabled", disabled: true, value: Date.new(2026, 10, 21))) %> <%= render(UI::DatePicker.new(name: "e", label: "Without the hint", hint: false)) %>
``` ## Dialog (`UI::Dialog`) A modal dialog on the native element. Add it: `bin/rails generate velvetkit:add dialog` Also adds: `icon`, `spinner`, `button` Stimulus: `ui--dialog` ### Arguments ```ruby UI::Dialog.new(class: nil, **html_attrs) ``` Slots: `with_trigger`, `with_body`, `with_content`, `with_title`, `with_description`, `with_footer` ### Notes UI::Dialog — modal dialog backed by the native element + showModal(). Why native : - Browser owns the modal mode, focus trap, ESC handling, and inert background. We do not reimplement any of that in JS. - The Stimulus controller only opens, closes, and dismisses on backdrop click. Everything else is delegated to the platform. Compound: UI::Dialog UI::Dialog::Trigger — wraps any element with a click action UI::Dialog::Content — the 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

Left content

<%= render(UI::Divider.new(orientation: :vertical)) %>

Right content

``` ### 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 . 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.

Team content goes here.

<% 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| %>
--<%= 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" } %>
```