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/vanillaPin @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
Yes. Keyboard navigation, ARIA roles, and aria-expanded are all wired by the Zag machine.
Yes. Tailwind utility classes with full theming support via CSS variables.
Yes. Height transitions use a CSS grid-rows trick — no keyframes, no extra dependencies.
render Components::UI::Accordion.new do
render Components::UI::AccordionItem.new(value: "q1") do
render Components::UI::AccordionTrigger.new(value: "q1") { "Is it accessible?" }
render Components::UI::AccordionContent.new(value: "q1") do
"Yes. Keyboard navigation, ARIA roles, and aria-expanded are all wired by the Zag machine."
end
end
render Components::UI::AccordionItem.new(value: "q2") do
render Components::UI::AccordionTrigger.new(value: "q2") { "Is it styled?" }
render Components::UI::AccordionContent.new(value: "q2") do
"Yes. Tailwind utility classes with full theming support via CSS variables."
end
end
render Components::UI::AccordionItem.new(value: "q3") do
render Components::UI::AccordionTrigger.new(value: "q3") { "Is it animated?" }
render Components::UI::AccordionContent.new(value: "q3") do
"Yes. Height transitions use a CSS grid-rows trick — no keyframes, no extra dependencies."
end
end
end
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.