Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Installation
Included in poetry-ui — available as
poetry_accordion
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add accordion
Default
<%= poetry_accordion(collapsible: true, open: %w[shipping], class: "max-w-md") do |accordion| %>
<% accordion.with_item(value: "shipping", title: "Shipping") { "Free over $50, worldwide." } %>
<% accordion.with_item(value: "returns", title: "Returns") { "30 days, no questions." } %>
<% accordion.with_item(value: "warranty", title: "Warranty") { "Two years, parts and labor." } %>
<% end %>
Borders
<%# Borders are class composition, not an API: a border on the root,
border-b + padding per item, trimmed on the last (upstream parity). %>
<%= poetry_accordion(collapsible: true, open: %w[billing], class: "max-w-lg rounded-lg border") do |accordion| %>
<% accordion.with_item(value: "billing", title: "How does billing work?", class: "border-b px-4 last:border-b-0") { "Monthly and annual plans, charged at the start of each cycle — cancel anytime. Every plan includes automatic backups, 24/7 support, and unlimited team members." } %>
<% accordion.with_item(value: "security", title: "Is my data secure?", class: "border-b px-4 last:border-b-0") { "Yes. End-to-end encryption, SOC 2 Type II compliance, and regular third-party audits — encrypted at rest and in transit." } %>
<% accordion.with_item(value: "integration", title: "What integrations do you support?", class: "border-b px-4 last:border-b-0") { "500+ tools including Slack, Zapier, Salesforce, and HubSpot — plus custom integrations over the REST API and webhooks." } %>
<% end %>
Card
<%# Card: each item is its own rounded, bordered card with a gap between
them - vs. the flush dividers of the borders example. The root becomes a
gapped flex column; each item carries the card classes. Pure class
composition, no new API. %>
<%= poetry_accordion(collapsible: true, open: %w[billing], class: "flex max-w-lg flex-col gap-2") do |accordion| %>
<% accordion.with_item(value: "billing", title: "How does billing work?", class: "rounded-lg border bg-card px-4") { "Monthly and annual plans, charged at the start of each cycle - cancel anytime. Every plan includes automatic backups, 24/7 support, and unlimited team members." } %>
<% accordion.with_item(value: "security", title: "Is my data secure?", class: "rounded-lg border bg-card px-4") { "Yes. End-to-end encryption, SOC 2 Type II compliance, and regular third-party audits - encrypted at rest and in transit." } %>
<% accordion.with_item(value: "integration", title: "What integrations do you support?", class: "rounded-lg border bg-card px-4") { "500+ tools including Slack, Zapier, Salesforce, and HubSpot - plus custom integrations over the REST API and webhooks." } %>
<% end %>
Disabled
<%= poetry_accordion(collapsible: true, class: "max-w-md") do |accordion| %>
<% accordion.with_item(value: "history", title: "Can I access my account history?") { "Yes — the complete history of transactions, plan changes, and support tickets lives in the Account History section of your dashboard." } %>
<% accordion.with_item(value: "premium", title: "Premium feature information", disabled: true) { "This section contains information about premium features. Upgrade your plan to access this content." } %>
<% accordion.with_item(value: "email", title: "How do I update my email address?") { "Update it in account settings — you'll receive a verification email at the new address to confirm the change." } %>
<% end %>
Multiple
<%= poetry_accordion(type: :multiple, open: %w[a b], class: "max-w-md") do |accordion| %>
<% accordion.with_item(value: "a", title: "First") { "Open together" } %>
<% accordion.with_item(value: "b", title: "Second") { "with the first." } %>
<% end %>
Non collapsible
<%# Without collapsible: the primitive default - one item is always open;
the open item's trigger locks (aria-disabled) until another opens. %>
<%= poetry_accordion(open: %w[always], class: "max-w-md") do |accordion| %>
<% accordion.with_item(value: "always", title: "One stays open") { "Opening another item is the only way to close me." } %>
<% accordion.with_item(value: "other", title: "The other item") { "Now the first one could close." } %>
<% end %>
API
Poetry::Ui::Accordion::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| collapsible: | Boolean | defaults to false |
With type: :single, allows the open section to be closed again. |
| heading_level: | Symbol | one of: h2, h3, h4, h5, h6; defaults to :h3 |
The heading element wrapping each trigger; pick it to fit the page outline. |
| open: | Array | defaults to -> { [] } |
Value keys of the sections rendered expanded on load. |
| type: | Symbol | one of: single, multiple; defaults to :single |
Whether one section (:single) or several (:multiple) may be open at once. |
Slots
| Writer | Description |
|---|---|
| with_item | The accordion sections. Each takes value: (its open-state key), title:, and a block of panel content; disabled: true locks the section closed. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=accordion] | The list root - both controllers (the open-set machine and roving focus) ride here |
| [data-slot=accordion-item] | One value-keyed section wrapping its header and panel |
| [data-slot=accordion-header] | The heading element (heading_level:, h3 default) hosting the trigger button |
| [data-slot=accordion-trigger] | The toggle button inside the header - the chevron rotation rides aria-expanded, not a data attribute |
| [data-slot=accordion-trigger-icon] | The chevron svg inside the trigger (aria-hidden) - rotates with the item |
| [data-slot=accordion-content] | The role=region panel - the presence animation and the measured height var ride here |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| accordion | data-orientation | always vertical - the only axis the accordion ships | vertical |
| accordion-item | data-open | the item is expanded (server-rendered from open:; the controller flips the pair at runtime) | — |
| accordion-item | data-closed | the item is collapsed | — |
| accordion-item | data-value | the item's open-state key (always present) | — |
| accordion-item | data-disabled | with_item(disabled: true) - the item is locked (styling hook; the trigger carries the native disabled attribute) | — |
| accordion-trigger | data-panel-open | its panel is open (controller-written; absent while closed) | — |
| accordion-trigger | data-disabled | with_item(disabled: true) - stamped beside the native disabled attribute; roving focus filters it out at query time | — |
| accordion-content | data-open | panel is open or entering | — |
| accordion-content | data-closed | panel is closed or animating out (hidden lands after the exit finishes) | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| accordion-content | --accordion-panel-height | the measured content height (controller-written) that feeds the accordion-down/up keyframes |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--accordion | registers · value type · value collapsible |
| root | poetry--core--roving-focus | registers · value orientation · value manage_tabindex · keydown on keydown |
| trigger | poetry--core--accordion | toggle on click |