Tabs

WAI-ARIA APG-compliant tabbed navigation. Selecting a tab shows its panel — everything else (roles, roving tabindex, keyboard nav, optional deep-linking) is wired by a single Stimulus controller reading server-rendered markup.

Key Features

  • Four visual variants:underline, :pills, :boxed, :spaced (our flagship design)
  • Full WAI-ARIA APG tabs pattern — explicit aria-selected on every tab, roving tabindex, wrap-around arrow navigation
  • Horizontal + vertical orientationtablist_position: :end flips sides visually without reordering the DOM
  • Icons and badges in triggers — a badge: shortcut for one counter, or a badges: array for several
  • Per-tab color overridecolor: on rui_tabs sets the default; with_tab(color:) overrides a single tab
  • Optional deep-linkingdeep_link: true reads/writes location.hash without extra history entries
  • Reduced-motion aware — the fade-in panel reveal is fully suppressed under prefers-reduced-motion
  • Pro tier — one Stimulus controller (rui--tabs), server-rendered structure and styling

Basic Usage

Pass a block and populate it with with_tab calls. The first tab is active by default — mark a different one with active: true to change the initial selection.

A flexible online sale format that allows buyers more time to review and bid.

Basic Usage
<%= rui_tabs(id: "pricing") do |t| %>
  <% t.with_tab(id: "overview", label: "Overview") do %>
    Overview panel content
  <% end %>
  <% t.with_tab(id: "fees", label: "Fees") do %>
    Fees panel content
  <% end %>
<% end %>

The Spaced Variant

Standalone rounded blocks with a gap between them — not connected. The active tab is a solid, dark color: block with white text; inactive tabs are a soft tint of the same color. A hairline divider under the row is on by default for this variant (off by default for the other three). Panel content can nest another rui_tabs — here, Become a User holds a smaller, differently-colored subtab row.

A flexible online sale format that allows buyers more time to review and bid before the countdown closes lot by lot.

Spaced Variant with Nested Subtabs
<%= rui_tabs(id: "listing-type", variant: :spaced, color: :primary) do |t| %>
  <% t.with_tab(id: "overview", label: "Overview", icon: "layout-grid") do %>
    Overview panel content
  <% end %>
  <% t.with_tab(id: "become-a-user", label: "Become a User", icon: "user-plus") do %>
    <%# Nested subtabs — smaller, different color, same API %>
    <%= rui_tabs(id: "become-a-user-role", variant: :spaced, size: :sm, color: :secondary) do |st| %>
      <% st.with_tab(id: "vendor", label: "Vendor") { "Vendor content" } %>
      <% st.with_tab(id: "buyer", label: "Buyer") { "Buyer content" } %>
      <% st.with_tab(id: "agency", label: "Agency") { "Agency content" } %>
    <% end %>
  <% end %>
  <% t.with_tab(id: "fees", label: "Fees", icon: "coins") do %>
    Fees panel content
  <% end %>
<% end %>

Variants

Four visual variants, all sharing the same API.

:underline (default)

Connected row, transparent bg. Active tab gets a 2px bottom border.

:pills

Rounded-full triggers. Active gets a solid color background.

:boxed

Tablist has a bottom border; the active tab merges into the panel.

:spaced

Standalone rounded blocks. Active = solid dark bg; inactive = soft tint.

Variants
<%= rui_tabs(id: "t1", variant: :underline) do |t| %>
<%= rui_tabs(id: "t2", variant: :pills) do |t| %>
<%= rui_tabs(id: "t3", variant: :boxed) do |t| %>
<%= rui_tabs(id: "t4", variant: :spaced) do |t| %>

Tabs Options

Tabs-level options control variant, color, size, orientation, and behavior.

Tabs Options
rui_tabs(
  id:,                          # required — prefix for every tab/panel DOM id
  variant: :underline,          # :underline | :pills | :boxed | :spaced
  color: :primary,              # semantic color, resolved via ColorBuilderHelper
  size: :base,                  # :sm | :base | :lg
  orientation: :horizontal,     # :horizontal | :vertical
  tablist_position: :start,     # vertical only — :start (left) | :end (right)
  divider: nil,                 # nil = variant default (:spaced -> true, others -> false)
  mobile_layout: :row,          # horizontal only — :row | :grid (2-col grid below md)
  full_width: false,            # horizontal only — tabs stretch equally (flex-1)
  activation: :automatic,       # :automatic (arrow focus selects) | :manual (Enter/Space selects)
  deep_link: false,             # read/write location.hash on switch
  animation: :fade,             # :fade | :none — suppressed under prefers-reduced-motion
  panels: true,                 # false renders the tablist only (external panels)
  active_shade: nil,            # :pills/:spaced only — 500/600/700/800/900/950, nil = color's own default
  active_icon_color: nil,       # tint the ACTIVE tab's icon (nil = matches label, unchanged)
  active_icon_shade: 300,       # 100/200/300/400/500 — ignored unless active_icon_color: is set
  active_bg_color: nil,         # :underline/:boxed only — light fill for the ACTIVE trigger, nil = variant default
  active_bg_shade: 100,         # 50/100/200/300/400 — ignored unless active_bg_color: is set
  inactive_bg_color: nil,       # :underline/:boxed only — light fill for INACTIVE triggers, nil = variant default
  inactive_bg_shade: 100        # 50/100/200/300/400 — ignored unless inactive_bg_color: is set
) do |t|
  # ...tabs
end

Active Shade & Icon Color (v0.55.0)

active_shade: and active_icon_color:/active_icon_shade: are :pills/:spaced-only escape hatches. active_shade: overrides the active trigger's solid bg/hover/border/focus-ring shade — normally fixed per color category (900 for :primary-style colors) with no way to dial it down. active_icon_color: tints only the active tab's icon a different color than its (unchanged) label text; active_icon_shade: (default 300) controls how light that tint is. Both are nil by default, so existing :pills/:spaced tabs render byte-identical output.

active_shade: 600 pulls the active tab's solid background down from the default 900 to 600.

What's controlling what: active_shade: 600 is the shade control — it darkens/lightens the active tab's solid background (and its hover/border/focus-ring) without touching inactive tabs or dark mode. active_icon_color: :sky is the icon-tint control — it colors only the active tab's icon (sky-300, via the default active_icon_shade: 300) while the label text stays white. Switch tabs above to see both apply only to whichever tab is active.

Active Shade + Icon Color
<%= rui_tabs(id: "commercial", variant: :spaced, color: :primary, active_shade: 600, active_icon_color: :sky, full_width: true) do |t| %>
  <% t.with_tab(id: "overview", label: "Overview", icon: "layout-grid", active: true) do %>
    Overview panel content
  <% end %>
  <% t.with_tab(id: "sale-dates", label: "Sale Dates", icon: "calendar") do %>
    Sale Dates panel content
  <% end %>
  <% t.with_tab(id: "fees", label: "Fees", icon: "coins") do %>
    Fees panel content
  <% end %>
<% end %>

Active & Inactive Background Fill (v0.56.2 / v0.56.4)

active_bg_color:/active_bg_shade: and inactive_bg_color:/inactive_bg_shade: are :underline/:boxed-only escape hatches — both variants render no background on their active OR inactive triggers by default (border/text only on :underline, plain white/zinc-900 active fill on :boxed). Each pair gives its own trigger state a light color fill, independent of color:. All four are nil/default by default, so existing :underline/:boxed tabs render byte-identical output. active_bg_color:/active_bg_shade: shipped in v0.56.2; inactive_bg_color:/inactive_bg_shade: is the v0.56.4 mirror for the inactive state. The two compose freely on the same tabs instance.

active_bg_color: :primary gives this active tab a light primary fill instead of the default no-background :underline look.

What's controlling what: active_bg_color: :primary tints only the currently active tab; inactive_bg_color: :zinc tints every other tab. Each reads a completely independent trigger-state class string, so switching tabs never lets one fill bleed into or get eaten by the other.

Active + Inactive Background Fill
<%= rui_tabs(id: "pricing", variant: :underline, color: :primary, active_bg_color: :primary, active_bg_shade: 100, inactive_bg_color: :zinc, inactive_bg_shade: 50) do |t| %>
  <% t.with_tab(id: "overview", label: "Overview", active: true) do %>
    Overview panel content
  <% end %>
  <% t.with_tab(id: "fees", label: "Fees") do %>
    Fees panel content
  <% end %>
<% end %>

Sizes

Three sizes — sm, base (default), lg — scale trigger padding, text, icon, and badge size together.

:sm

One

:base (default)

One

:lg

One
Sizes
<%= rui_tabs(id: "t", size: :sm) do |t| %>
<%= rui_tabs(id: "t", size: :base) do |t| %>
<%= rui_tabs(id: "t", size: :lg) do |t| %>

Vertical Orientation

orientation: :vertical turns the tablist into a column (shrink-0); panels take the remaining width (flex-1). The tablist carries aria-orientation="vertical", and ArrowDown/ArrowUp move focus instead of ArrowRight/ArrowLeft.

Update your name, email, and profile photo.

Vertical Orientation
<%= rui_tabs(id: "settings", orientation: :vertical, tablist_position: :start) do |t| %>
  <% t.with_tab(id: "profile", label: "Profile", icon: "settings") { "..." } %>
  <% t.with_tab(id: "notifications", label: "Notifications", icon: "bell") { "..." } %>
  <% t.with_tab(id: "billing", label: "Billing", icon: "credit-card") { "..." } %>
<% end %>

Tablist Position (Vertical)

tablist_position: :end flips which side the tablist sits on with flex-row-reverse, not by reordering the DOM — keyboard focus order always matches DOM order regardless of which side the tablist visually sits on.

Update your name, email, and profile photo.

Tablist Position
<%= rui_tabs(id: "settings", orientation: :vertical, tablist_position: :end) do |t| %>
  <%# same tabs, tablist rendered on the right %>
<% end %>

Icons

icon: accepts any Lucide icon name. icon_position: :start (default) or :end places it before or after the label — :left/:right are accepted as aliases and normalized.

icon_position: :start

Grid panel

icon_position: :end

Grid panel
Icons
<% t.with_tab(id: "grid", label: "Grid", icon: "layout-grid", icon_position: :start) %>
<% t.with_tab(id: "list", label: "List", icon: "list", icon_position: :end) %>

Badges

badge:/badge_color: is a shortcut for a single trailing counter badge. For more than one — or a leading badge — use the badges: array. Render order: leading badges → icon (:start) → label → icon (:end) → trailing badges → the badge: shortcut (always last).

Shortcut counter badge

Inbox panel

Leading + trailing badges

Inbox panel
Badges
<%# Shortcut — single trailing counter badge %>
<% t.with_tab(id: "inbox", label: "Inbox", badge: 5, badge_color: :danger) %>

<%# Multiple badges — leading + trailing %>
<% t.with_tab(
     id: "inbox",
     label: "Inbox",
     badges: [
       { text: "New", color: :accent, position: :leading },
       { text: 5, color: :danger }                 # position: defaults to :trailing
     ]
   ) %>

Disabled Tab

disabled: true renders aria-disabled="true", applies muted/opacity styling, and is skipped entirely during arrow-key traversal (Home/End skip it too).

Overview panel content.

Disabled Tab
<% t.with_tab(id: "archived", label: "Archived", disabled: true) %>

Per-Tab Color Override

color: on rui_tabs sets the default for every tab; color: on an individual with_tab overrides it for that one tab only.

Uses the tabs-level color: :primary.

Per-Tab Color Override
<%= rui_tabs(id: "t", color: :primary) do |t| %>
  <% t.with_tab(id: "overview", label: "Overview") %>          <%# :primary %>
  <% t.with_tab(id: "alerts", label: "Alerts", color: :danger) %>  <%# overridden %>
<% end %>

Mobile Layout (2x2 Grid)

mobile_layout: :grid switches the tablist to a 2-column grid below the md breakpoint, and back to a row from md up (horizontal only). The breakpoint reads actual browser viewport width, not this container's width — resize your browser (or open dev tools' responsive mode) below 768px to see the grid.

Overview panel.

Mobile Layout
<%= rui_tabs(id: "listing-type", variant: :spaced, mobile_layout: :grid) do |t| %>
  <%# 4 tabs render as a 2x2 grid below md, a single row from md up %>
<% end %>

Nested Tabs

Panel content is arbitrary HTML/ERB/components — nested rui_tabs inside a panel works out of the box; each nested instance gets its own Stimulus controller scope. See the Spaced Variant demo above for the full Become a User example (spaced outer tabs, small spaced inner subtabs in a different color).

Nested Tabs Recipe
<%= rui_tabs(id: "outer", variant: :spaced, color: :primary) do |t| %>
  <% t.with_tab(id: "become-a-user", label: "Become a User", icon: "user-plus") do %>
    <%# Nested subtabs — smaller, different color, same API %>
    <%= rui_tabs(id: "outer-inner", variant: :spaced, size: :sm, color: :secondary) do |st| %>
      <% st.with_tab(id: "vendor", label: "Vendor") { "Vendor content" } %>
      <% st.with_tab(id: "buyer", label: "Buyer") { "Buyer content" } %>
      <% st.with_tab(id: "agency", label: "Agency") { "Agency content" } %>
    <% end %>
  <% end %>
<% end %>

Jump to the Spaced Variant demo

Manual Activation

activation: :automatic (default) selects a tab as soon as arrow-key focus reaches it. activation: :manual requires an explicit Enter/Space to activate a focused tab — arrow keys only move focus. Manual mode suits panels that are expensive to render or fetch, where you don't want every arrow press to trigger a switch.

Arrow to a tab, then press Enter or Space to activate it.

Manual Activation
<%= rui_tabs(id: "t", activation: :manual) do |t| %>
  <%# arrow keys move focus only; Enter/Space activates %>
<% end %>

External Panels (panels: false)

panels: false renders the tablist only — role="tab", aria-selected, roving tabindex, and full keyboard navigation still work out of the box for the triggers themselves.

Verified against v0.52.0 source: when panels: false, aria-controls is omitted from every trigger entirely (there's no built-in panel to point at), and the Stimulus controller's panel targets are scoped to elements inside the tabs root — external panels placed outside it are not auto-toggled. Wire visibility yourself: add aria-controls and a hidden-attribute toggle by hand, or listen for the tabs:change custom event (detail: { tab: <raw id> }) that the controller dispatches on every switch and drive your own panels from it.

Step 1 content, wired up and toggled by hand.

External Panels — wired by hand via tabs:change
<%= rui_tabs(id: "wizard", panels: false) do |t| %>
  <% t.with_tab(id: "step-1", label: "Step 1") %>
  <% t.with_tab(id: "step-2", label: "Step 2") %>
<% end %>

<div id="wizard-panel-step-1" role="tabpanel" aria-labelledby="wizard-tab-step-1" tabindex="0" data-wizard-panel="step-1">
  Step 1 content.
</div>
<div id="wizard-panel-step-2" role="tabpanel" aria-labelledby="wizard-tab-step-2" tabindex="0" data-wizard-panel="step-2" hidden>
  Step 2 content.
</div>

<%# aria-controls isn't rendered when panels: false, so drive visibility
    from the "tabs:change" event the controller dispatches on every switch %>
<script>
  document.getElementById("wizard").addEventListener("tabs:change", (event) => {
    document.querySelectorAll("[data-wizard-panel]").forEach((panel) => {
      panel.hidden = panel.dataset.wizardPanel !== event.detail.tab
    })
  })
</script>

Lazy Panel Content

No dedicated lazy-mount API. Put a turbo_frame_tag inside the panel block instead — this is the intended recipe for large or fetched-on-demand panel content.

Lazy Panel Content
<% t.with_tab(id: "activity", label: "Activity") do %>
  <%= turbo_frame_tag "activity-frame", src: activity_path, loading: :lazy do %>
    Loading…
  <% end %>
<% end %>

Accessibility

Tabs is our accessibility showcase — the full WAI-ARIA APG tabs pattern is implemented, not approximated.

ARIA contract

Element Attribute Notes
tablistrole="tablist"On the wrapping element around every trigger.
tablistaria-orientation="vertical"Present only when orientation: :vertical; horizontal is the implicit default (omitted).
tab<button type="button" role="tab">Never a link — no href:-triggered tabs.
tabaria-selected="true"|"false"Set explicitly on every tab, including "false" — never omitted.
tabaria-controlsPoints at the matching panel's id ("#{id}-panel-#{tab_id}") when panels: true (default). Omitted entirely when panels: false — see External Panels below.
tabidUnique per tab ("#{id}-tab-#{tab_id}"); referenced by the panel's aria-labelledby.
tabtabindex="0"|"-1"Roving tabindex — only the active tab is 0; Tab key moves straight from the active tab into its panel.
tabaria-disabled="true"Set when disabled: true; the tab is also skipped entirely during arrow-key traversal.
panelrole="tabpanel"One per tab, rendered in a wrapper below (horizontal) or beside (vertical) the tablist.
panelaria-labelledbyPoints back at the owning tab's id.
paneltabindex="0"Always present — panels host arbitrary content, so we can't inspect the block server-side to know if it already has a focusable element.
panelhiddenInactive panels are hidden via the HTML hidden attribute, not a display utility class — no-JS degrades gracefully to the first panel visible.

Keyboard support — horizontal

KeyAction
TabMove focus onto the active tab (roving tabindex); a second Tab moves into the active panel.
Arrow RightMove focus to the next enabled tab (wraps to the first at the end).
Arrow LeftMove focus to the previous enabled tab (wraps to the last at the start).
HomeJump to the first enabled tab.
EndJump to the last enabled tab.
Enter / SpaceActivate the focused tab (only needed in activation: :manual — automatic mode activates on arrow focus).

Up/Down are never bound in horizontal orientation, so vertical scrolling stays native.

Keyboard support — vertical

KeyAction
TabMove focus onto the active tab (roving tabindex); a second Tab moves into the active panel.
Arrow DownMove focus to the next enabled tab (wraps to the first at the end).
Arrow UpMove focus to the previous enabled tab (wraps to the last at the start).
HomeJump to the first enabled tab.
EndJump to the last enabled tab.
Enter / SpaceActivate the focused tab (activation: :manual only).

Left/Right are never bound in vertical orientation. Which arrow keys move focus is decided in the rendered data-action per orientation — the Stimulus controller never branches on orientation at runtime, so a horizontal tablist can never receive an Up/Down binding and vice versa.

Automatic vs. manual activation

:automatic (default)

Arrow-key focus immediately selects the tab and shows its panel. Matches the APG's "automatic activation" recommendation — best when switching panels is cheap.

:manual

Arrow keys only move focus; the user must press Enter or Space to activate. Use this when activating a tab is expensive (a fetch, a heavy render) so arrowing through tabs doesn't trigger it repeatedly.

Focus visibility

Triggers always ship a visible focus-visible ring (ring-2/ring-offset-0, 700 light / 400 dark) — the same recipe Combobox and Select use. We shipped an invisible focus ring once; never again.

Reduced motion

animation: :fade's opacity transition is fully skipped under prefers-reduced-motion: reduce — the panel still shows instantly on switch, just without the fade. animation: :none disables the transition unconditionally.

API Reference

rui_tabs

WAI-ARIA APG-compliant tabs. Selecting a tab shows its panel; one Stimulus controller (rui--tabs) wires roles, roving tabindex, keyboard nav, and optional deep-linking against server-rendered markup.

Parameter Type Default Description
id* String Required — prefix for every tab/panel DOM id ("#{id}-tab-#{tab_id}" / "#{id}-panel-#{tab_id}")

Appearance

Visual styling options

Parameter Type Default Description
variant Symbol :underline Visual style
:underline :pills :boxed :spaced
color Symbol :primary Semantic color, resolved via ColorBuilderHelper — sets the default for every tab
size Symbol :base Trigger size — scales padding, text, icon, and badge size together
:sm :base :lg
divider Boolean nil (variant default) Hairline between tablist and panels. nil resolves to the variant default (:spaced -> true, others -> false)
class String Additional root classes, appended (not tailwind_merge'd — no conflicting-class resolution)

Orientation & Layout

Horizontal vs. vertical layout, and responsive/full-width behavior

Parameter Type Default Description
orientation Symbol :horizontal Tablist direction
:horizontal :vertical
tablist_position Symbol :start Vertical only — which side the tablist sits on, flipped via flex-row-reverse (not DOM reorder)
:start :end
mobile_layout Symbol :row Horizontal only — :grid renders a 2-column grid below md, a row from md up
:row :grid
full_width Boolean false Horizontal only — tabs stretch equally via flex-1

Active Tab Styling (v0.55.0)

:pills/:spaced only — override the active trigger's solid shade and tint its icon independently of the label

Parameter Type Default Description
active_shade Integer nil (color's own category default) Override the active trigger's solid bg/hover/border/focus-ring shade — normally fixed at 900 for :primary-style colors, 700 for standard colors, 400 for the bright/:warning family. An invalid value warns (dev/test) and falls back to 900. Focus ring stays capped at 900 regardless of the override. Dark mode is unaffected.
500 600 700 800 900 950
active_icon_color Symbol Tint the ACTIVE tab's icon a different color than its label text (e.g. a light accent on a dark :spaced solid background). nil (default) means the icon matches the label, unchanged from pre-0.55.0 behavior. Inactive tabs are never affected.
active_icon_shade Integer 300 Shade for active_icon_color:. Ignored unless active_icon_color: is set.
100 200 300 400 500

Active & Inactive Background Fill (v0.56.2 / v0.56.4)

:underline/:boxed only — give the active or inactive triggers a light color fill, independent of color:

Parameter Type Default Description
active_bg_color Symbol nil (variant default) Light color fill for the ACTIVE trigger. nil leaves :underline (no background) and :boxed (plain white/dark:zinc-900) exactly as before. Ignored on :pills/:spaced, which already render their own solid active background.
active_bg_shade Integer 100 Shade for active_bg_color:. Ignored unless active_bg_color: is set.
50 100 200 300 400
inactive_bg_color Symbol nil (variant default) Light color fill for every INACTIVE trigger — the mirror image of active_bg_color:. nil leaves :underline/:boxed exactly as before. Ignored on :spaced (already soft-tinted) and :pills (established transparent-until-hover convention).
inactive_bg_shade Integer 100 Shade for inactive_bg_color:. Ignored unless inactive_bg_color: is set.
50 100 200 300 400

Behavior

Activation mode, deep-linking, animation, and panel rendering

Parameter Type Default Description
activation Symbol :automatic arrow focus selects (:automatic) or requires Enter/Space (:manual)
:automatic :manual
deep_link Boolean false Read location.hash on connect and write it back (history.replaceState) on every switch; listens for hashchange
animation Symbol :fade Panel reveal transition — fully suppressed under prefers-reduced-motion
:fade :none
panels Boolean true false renders the tablist only — aria-controls is omitted (no built-in panel to point at); wire your own external panel visibility, e.g. via the tabs:change event

Tab Options (with_tab)

Options for t.with_tab() — one trigger + panel pair

Parameter Type Default Description
id* String Required — becomes the tab/panel DOM id suffix
label* String Required — trigger label text
icon String/Symbol Lucide icon name rendered in the trigger
icon_position Symbol :start :left/:right accepted as aliases and normalized to :start/:end
:start :end
active Boolean false Exactly one tab ends up active — the first tab with active: true wins; if none is flagged, the first tab in render order is used
disabled Boolean false aria-disabled + skipped entirely during arrow-key traversal (including Home/End)
color Symbol nil (inherits tabs-level color:) Per-tab override of the tabs-level color:
badge String/Integer Shortcut — a single trailing counter badge, always rendered last
badge_color Symbol :muted Color for the badge: shortcut

Multiple Badges (badges:)

Array fallback on with_tab for more than one badge, or a leading badge — the block already captures panel content, so a nested renders_many slot isn't available here (see USAGE.md for why)

Key Type Default Description
text* String/Integer Badge text — text: or value: both accepted
color Symbol :muted Badge color
position Symbol :trailing Render before or after the icon+label
:leading :trailing

Slots

Content slots for customizing component parts

Slot Description
with_tab One trigger + panel pair. t.with_tab(id:, label:, icon:, icon_position:, active:, disabled:, color:, badge:, badge_color:, badges:) { panel content }. Panel content is arbitrary HTML/ERB/components — nested rui_tabs is supported.