ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Accordion

Accessible accordion with single/multiple open modes and keyboard navigation.

Installation

bin/rails g wabi:add accordion
bin/importmap pin @zag-js/accordion
bin/importmap pin @zag-js/vanilla

Pin @zag-js/accordion 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

Source

app/components/ui/accordion.rb

# frozen_string_literal: true

require "date"
require "json"

module Components
  module UI
    class Accordion < Wabi::Base
      def initialize(type: :single, value: [], collapsible: true, id: nil, **attrs)
        @id          = id
        @type        = type
        @value       = Array(value)
        @collapsible = collapsible
        @attrs       = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          id: @id,
          data: {
            controller: "wabi--accordion",
            "wabi--accordion-multiple-value":    (@type == :multiple).to_s,
            "wabi--accordion-value-value":       @value.to_json,
            "wabi--accordion-collapsible-value": @collapsible.to_s,
          },
          class: user_class
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/accordion_item.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class AccordionItem < Wabi::Base
      variants do
        base "border-b border-border"
      end

      def initialize(value:, **attrs)
        @value = value
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: {
            "wabi--accordion-target": "item",
            "wabi-value": @value,
          },
          class: merge_class(tokens, user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/accordion_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class AccordionTrigger < Wabi::Base
      variants do
        base "flex flex-1 items-center justify-between py-4 text-sm font-medium " \
             "transition-all hover:underline focus-visible:outline-none " \
             "focus-visible:ring-2 focus-visible:ring-ring " \
             "[&[data-state=open]>svg]:rotate-180"
      end

      # level: (Integer 1-6, default 3) controls the heading element wrapping the
      # trigger button, mirroring CardTitle. Pass level: nil to opt out of heading
      # semantics entirely (renders a plain <div> wrapper).
      def initialize(value:, level: 3, **attrs)
        @value = value
        @level = level.nil? ? nil : level.to_i.clamp(1, 6)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        wrapper = @level.nil? ? :div : :"h#{@level}"
        send(wrapper, class: "flex") do
          button(
            type: "button",
            data: {
              "wabi--accordion-target": "trigger",
              "wabi-value": @value,
            },
            class: merge_class(tokens, user_class)
          ) do
            yield if block_given?
            raw(safe('<svg aria-hidden="true" focusable="false" class="h-4 w-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>'))
          end
        end
      end
    end
  end
end

app/components/ui/accordion_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Content uses a CSS-grid height-animation trick: outer is `grid` with
    # `grid-rows-[0fr]` when closed and `grid-rows-[1fr]` when open, animating
    # `grid-template-rows`. The inner wrapper is `overflow-hidden` so the body
    # is clipped during the transition. No keyframes / no tailwindcss-animate
    # needed -- works in Chrome 117+, Safari 17.4+, Firefox 119+.
    #
    # `hidden:false` is forced by the controller after Zag's spreadProps to
    # keep transitions from being short-circuited by `display:none` (same
    # pattern Sprint 4 cleanup adopted for the overlays).
    class AccordionContent < Wabi::Base
      variants do
        base "grid text-sm transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none " \
             "data-[state=closed]:grid-rows-[0fr] data-[state=open]:grid-rows-[1fr]"
      end

      def initialize(value:, **attrs)
        @value = value
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          "data-state": "closed",
          data: {
            "wabi--accordion-target": "content",
            "wabi-value": @value,
          },
          class: merge_class(tokens, user_class)
        ) do
          div(class: "overflow-hidden") do
            div(class: "pb-4 pt-0") do
              yield if block_given?
            end
          end
        end
      end
    end
  end
end

Accessibility

  • Each AccordionContent has role="region" and aria-labelledby pointing to its trigger.
  • aria-expanded on the trigger reflects open/closed state.
  • Up/Down arrows move focus between triggers; Home/End jump to first/last; Enter or Space toggles.
  • type: :single (default) closes other items on open; type: :multiple permits any combination.