ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

Variants

Pass variant: :pill to Tabs for a rounded container with a solid-primary active pill. The default is the segmented look above.

Underline

Pass variant: :underline for a full-width, minimalist underline — the same style used by these Preview / Code switchers.

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.