Tabs
Accessible tabs with keyboard arrow navigation, automatic and manual activation.
Installation
bin/rails g wabi:add tabs
bin/importmap pin @zag-js/tabs
bin/importmap pin @zag-js/vanillaPin @zag-js/tabs and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
Manage your account settings here.
Change your password.
render Components::UI::Tabs.new(value: "account") do
render Components::UI::TabsList.new do
render Components::UI::TabsTrigger.new(value: "account") { "Account" }
render Components::UI::TabsTrigger.new(value: "password") { "Password" }
end
render Components::UI::TabsContent.new(value: "account") { p { "Account body" } }
render Components::UI::TabsContent.new(value: "password") { p { "Password body" } }
end
Variants
Pass variant: :pill to Tabs for a rounded container with a solid-primary active pill. The default is the segmented look above.
Upcoming match predictions.
League standings.
Recent results.
render Components::UI::Tabs.new(value: "predictions", variant: :pill) do
render Components::UI::TabsList.new do
render Components::UI::TabsTrigger.new(value: "predictions") { "Pronósticos" }
render Components::UI::TabsTrigger.new(value: "standings") { "Ranking" }
render Components::UI::TabsTrigger.new(value: "results") { "Resultados" }
end
render Components::UI::TabsContent.new(value: "predictions") { p { "Predictions" } }
render Components::UI::TabsContent.new(value: "standings") { p { "Standings" } }
render Components::UI::TabsContent.new(value: "results") { p { "Results" } }
end
Underline
Pass variant: :underline for a full-width, minimalist underline — the same style used by these Preview / Code switchers.
Account overview.
Recent activity.
Settings.
render Components::UI::Tabs.new(value: "overview", variant: :underline, class: "w-full") do
render Components::UI::TabsList.new do
render Components::UI::TabsTrigger.new(value: "overview") { "Overview" }
render Components::UI::TabsTrigger.new(value: "activity") { "Activity" }
render Components::UI::TabsTrigger.new(value: "settings") { "Settings" }
end
render Components::UI::TabsContent.new(value: "overview") { p { "Overview" } }
render Components::UI::TabsContent.new(value: "activity") { p { "Activity" } }
render Components::UI::TabsContent.new(value: "settings") { p { "Settings" } }
end
Source
app/components/ui/tabs.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class Tabs < Wabi::Base
def initialize(value:, variant: :standard, activation_mode: :automatic, id: nil, **attrs)
@id = id
@value = value
@variant = variant
@activation_mode = activation_mode
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
id: @id,
data: {
controller: "wabi--tabs",
"wabi--tabs-value-value": @value,
"wabi--tabs-activation-mode-value": @activation_mode.to_s,
variant: @variant.to_s,
},
class: merge_class("group/tabs", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/tabs_list.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class TabsList < Wabi::Base
variants do
base "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground " \
"group-data-[variant=pill]/tabs:rounded-full " \
"group-data-[variant=pill]/tabs:gap-1 " \
"group-data-[variant=pill]/tabs:border " \
"group-data-[variant=pill]/tabs:border-border " \
"group-data-[variant=pill]/tabs:bg-muted/40 " \
"group-data-[variant=underline]/tabs:w-full " \
"group-data-[variant=underline]/tabs:justify-start " \
"group-data-[variant=underline]/tabs:gap-4 " \
"group-data-[variant=underline]/tabs:rounded-none " \
"group-data-[variant=underline]/tabs:bg-transparent " \
"group-data-[variant=underline]/tabs:border-b " \
"group-data-[variant=underline]/tabs:border-border " \
"group-data-[variant=underline]/tabs:px-4 " \
"group-data-[variant=underline]/tabs:py-0 " \
"group-data-[variant=underline]/tabs:h-auto"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
role: "tablist",
data: { "wabi--tabs-target": "list" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/tabs_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class TabsTrigger < Wabi::Base
variants do
base "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 " \
"text-sm font-medium transition-all motion-reduce:transition-none cursor-pointer focus-visible:outline-none " \
"focus-visible:ring-2 focus-visible:ring-ring " \
"aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed " \
"aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm " \
"group-data-[variant=pill]/tabs:rounded-full " \
"group-data-[variant=pill]/tabs:text-muted-foreground " \
"group-data-[variant=pill]/tabs:hover:text-foreground " \
"group-data-[variant=pill]/tabs:aria-selected:bg-primary " \
"group-data-[variant=pill]/tabs:aria-selected:text-primary-foreground " \
"group-data-[variant=pill]/tabs:aria-selected:shadow-none " \
"group-data-[variant=underline]/tabs:rounded-none " \
"group-data-[variant=underline]/tabs:bg-transparent " \
"group-data-[variant=underline]/tabs:shadow-none " \
"group-data-[variant=underline]/tabs:border-b-[3px] " \
"group-data-[variant=underline]/tabs:border-transparent " \
"group-data-[variant=underline]/tabs:px-4 " \
"group-data-[variant=underline]/tabs:py-3 " \
"group-data-[variant=underline]/tabs:-mb-px " \
"group-data-[variant=underline]/tabs:text-muted-foreground " \
"group-data-[variant=underline]/tabs:hover:text-foreground " \
"group-data-[variant=underline]/tabs:aria-selected:bg-transparent " \
"group-data-[variant=underline]/tabs:aria-selected:shadow-none " \
"group-data-[variant=underline]/tabs:aria-selected:border-b-primary " \
"group-data-[variant=underline]/tabs:aria-selected:text-primary " \
"group-data-[variant=underline]/tabs:aria-selected:font-bold"
end
def initialize(value:, disabled: false, **attrs)
@value = value
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
role: "tab",
data: {
"wabi--tabs-target": "trigger",
"wabi-value": @value,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/tabs_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class TabsContent < Wabi::Base
variants do
base "mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
end
def initialize(value:, **attrs)
@value = value
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
role: "tabpanel",
data: {
"wabi--tabs-target": "content",
"wabi-value": @value,
},
hidden: true,
tabindex: 0,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
Accessibility
- role="tablist" / role="tab" / role="tabpanel" anatomy.
- ←/→ arrow keys navigate between triggers; Home/End jump to first/last.
- data-[state=active] styles applied to the active trigger (bg-background + shadow).
- Inactive tabpanels render with hidden attribute; the active panel becomes tabbable (tabindex=0).
- Automatic vs manual activation: pass activation_mode: :manual if focusing a trigger shouldn't switch the panel.