Senren UI
Components / Tabs

Tabs

Stable Stimulus: senren--tabs

switching between related views in the same context

Track project health, activity, and pending work in one place.

Usage example #

Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.

app/views/.../tabs_example.html.erb
<div class="w-full max-w-lg">
  <%= render Senren::TabsComponent.new(items: [
    { id: "overview", label: "Overview", content: "Track project health, activity, and pending work in one place." },
    { id: "activity", label: "Activity", content: "Recent updates and team decisions appear here." },
    { id: "settings", label: "Settings", content: "Tune notifications, visibility, and access rules." }
  ]) %>
</div>

Install this component #

Copy the official component into your app

This component requires Stimulus. Keep --client so the controller is copied with the ViewComponent.

Terminal
bin/rails senren:add tabs --client

Create a custom component with the same conventions

Use this when you need an app-specific component that follows Senren's ViewComponent and Stimulus structure. --client is required for this behavior.

Terminal
bin/rails generate senren:component tabs --client

At a glance #

Category Navigation
Class name Senren::TabsComponent
Stimulus senren--tabs
Variants default, underline
Depends on
Pairs with card

Source #

app/components/senren/tabs_component.html.erb
<div <%= tag.attributes(**root_attrs("w-full", data: { controller: "senren--tabs" })) %>>
  <div role="tablist" aria-label="<%= label %>" class="<%= self.class::VARIANTS[variant] %> flex flex-wrap items-center gap-1">
    <% items.each do |item| %>
      <% selected = active_item?(item) %>
      <button type="button" role="tab" id="<%= item[:id] %>-tab" aria-selected="<%= selected %>" aria-controls="<%= item[:id] %>-panel" tabindex="<%= selected ? 0 : -1 %>" data-senren--tabs-target="tab" data-panel-id="<%= item[:id] %>" data-state="<%= selected ? "active" : "inactive" %>" data-action="click->senren--tabs#select keydown->senren--tabs#onKey" class="cursor-pointer rounded-[calc(var(--senren-radius)-2px)] px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] text-[hsl(var(--senren-muted-foreground))] data-[state=active]:bg-[hsl(var(--senren-background))] data-[state=active]:text-[hsl(var(--senren-foreground))] data-[state=active]:shadow-sm data-[state=inactive]:hover:text-[hsl(var(--senren-foreground))]">
        <%= item[:label] %>
      </button>
    <% end %>
  </div>
  <div class="mt-4">
    <% items.each do |item| %>
      <% selected = active_item?(item) %>
      <section id="<%= item[:id] %>-panel" role="tabpanel" aria-labelledby="<%= item[:id] %>-tab" data-senren--tabs-target="panel" data-panel-id="<%= item[:id] %>" data-state="<%= selected ? "active" : "inactive" %>" <%= "hidden" unless selected %> class="rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-4 text-sm text-[hsl(var(--senren-card-foreground))]">
        <%= item[:content].presence || content %>
      </section>
    <% end %>
  </div>
</div>
app/components/senren/tabs_component.rb
# frozen_string_literal: true

module Senren
  class TabsComponent < BaseComponent
    VARIANTS = {
      default: 'rounded-(--senren-radius) bg-[hsl(var(--senren-muted))] p-1',
      underline: 'border-b border-[hsl(var(--senren-border))]'
    }.freeze
    SIZES = { md: '' }.freeze

    def initialize(items: [], variant: :default, active: nil, label: 'Tabs', class_name: nil, **html)
      super(variant: variant, size: :md, class_name: class_name, **html)
      @items = normalize_items(items)
      @active = active&.to_s || @items.first&.fetch(:id, nil)
      @label = label
    end

    attr_reader :items, :active, :label

    def active_item?(item)
      item[:id].to_s == active.to_s
    end

    private

    def normalize_items(items)
      Array(items).map.with_index do |item, index|
        source = item.is_a?(Hash) ? item : { label: item.to_s }
        label = source[:label] || source['label'] || "Tab #{index + 1}"
        id = (source[:id] || source['id'] || label.to_s.parameterize).to_s
        { id: id, label: label, content: source[:content] || source['content'] }
      end
    end
  end
end

AI agent rules #

Use for

  • +switching between related views in the same context

Avoid

  • -non-related sections - use a separate page

Accessibility #

  • role=tablist with arrow-key navigation and aria-controls.