Fluxon.Components.Accordion
(Fluxon v3.1.2)
A vertical stack of collapsible sections that lets users expand and collapse panels of related content. Useful for FAQs, settings groups, navigation sub-trees, and any layout where content density needs to stay manageable.
Accordion is a compound component family backed by a small JavaScript controller. The server
renders the markup and an initial open/closed state per item, and the controller keeps focus
management, keyboard navigation, and item state in sync as the user toggles items or as
LiveView patches the DOM. Panel expansion is animated entirely in CSS using a
grid-template-rows transition, so there is no JavaScript-driven height calculation and
rapid toggles cannot glitch mid-animation.
The system has two pieces designed to be used together:
accordion/1: the container that mounts the JavaScript hook and configures the expansion mode (single vs multiple, whether all panels can close at once).accordion_item/1: a single section with a clickable header and a collapsible panel. The item body is the panel content; an optional:indicatorslot replaces the default chevron.
A typical structure looks like this:
accordion
├── accordion_item
│ ├── :header slot (button label)
│ └── :panel slot (collapsible content)
├── accordion_item
└── accordion_itemChoosing between accordion and tabs
Reach for accordion when sections are independently useful and several can stay open at
once, when the content stack should grow vertically (FAQs, settings groups, filter panels),
or when collapsed sections should still hint at what they contain. Reach for
Fluxon.Components.Tabs when only one panel should be visible at a time and the alternatives
are mutually exclusive views of the same area.
Usage
Single-section expansion (the default, where opening one item closes any other):
<.accordion>
<.accordion_item expanded>
<:header>What is Fluxon?</:header>
<:panel>A UI component library for Phoenix LiveView applications.</:panel>
</.accordion_item>
<.accordion_item>
<:header>How do I get started?</:header>
<:panel>Add Fluxon to your dependencies and follow the installation guide.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Where do I report issues?</:header>
<:panel>Open a ticket in the support portal.</:panel>
</.accordion_item>
</.accordion>Trigger buttons are keyboard operable. With focus on a trigger, Space or Enter toggles
that section, the up and down arrow keys move focus between triggers (wrapping at the ends and
skipping disabled items), and Home and End jump to the first or last trigger. Only the
focused trigger stays in the page tab sequence, so Tab steps out of the accordion once the
user is done navigating it.
Expansion Modes
Two boolean attributes on accordion/1 shape how the open set behaves. They combine: setting
both yields a "one or more" accordion that allows several open items but never zero.
| Attribute | Behavior |
|---|---|
| (default) | Only one item can be open at a time. Opening another closes the previous. Use for guided flows or FAQ-style lists where the user is reading one section at a time. |
multiple | Any number of items can be open simultaneously. Use for filter panels, reference checklists, or settings groups that the user wants to compare side by side. |
prevent_all_closed | At least one item must remain open. The last open item ignores close clicks. Use when collapsing the entire panel would leave the user with no context. |
<.accordion multiple>
<.accordion_item>
<:header>Section 1</:header>
<:panel>Content one.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Section 2</:header>
<:panel>Content two.</:panel>
</.accordion_item>
</.accordion>
<.accordion prevent_all_closed>
<.accordion_item expanded>
<:header>Always One Open</:header>
<:panel>This or another section will always be expanded.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Another Section</:header>
<:panel>More content here.</:panel>
</.accordion_item>
</.accordion>prevent_all_closed requires an initial open item
When prevent_all_closed is set, render at least one accordion_item with expanded. If
no item starts open, the controller has nothing to keep open and the constraint silently
does nothing on the first interaction.
Disabled Items
Mark an item as disabled to render its trigger with the native disabled attribute. The
controller refuses to toggle disabled items on click and skips them during arrow-key
navigation, but their content stays in the DOM and renders inside the panel as normal.
<.accordion>
<.accordion_item>
<:header>Available</:header>
<:panel>This section can be opened and closed.</:panel>
</.accordion_item>
<.accordion_item disabled>
<:header>Coming soon</:header>
<:panel>Locked content. The trigger button cannot be activated.</:panel>
</.accordion_item>
</.accordion>When a disabled item is rendered with expanded, the controller honors the server-rendered
state and shows the panel. The user cannot collapse it through the UI, only the server can.
Custom Indicator
Each item ships with a chevron that rotates 180 degrees when the item expands. To replace it
with a different visual, for example a plus/minus toggle, pass an :indicator slot. The slot
content sits where the chevron would and inherits the same data-expanded style hook from
the item, so any rotation, swap, or scale can be wired through Tailwind variants:
<.accordion>
<.accordion_item>
<:header>Custom indicator</:header>
<:indicator>
<.icon name="hero-plus" class="size-4 group-data-expanded/accordion-item:hidden" />
<.icon name="hero-minus" class="size-4 hidden group-data-expanded/accordion-item:block" />
</:indicator>
<:panel>Content with a plus/minus indicator instead of the default chevron.</:panel>
</.accordion_item>
</.accordion>Pass an empty :indicator slot to suppress the chevron entirely without providing a
replacement.
Server-Driven State
The expanded boolean on accordion_item is the source of truth. On mount and after every
LiveView patch, the controller re-reads which items are expanded and rebuilds its open set
from the server-rendered DOM, so driving expanded from an assign is enough to control the
accordion entirely from the server.
<.accordion id="settings" multiple>
<.accordion_item expanded={@open_section == :profile}>
<:header phx-click={JS.push("toggle_section", value: %{section: "profile"})}>
Profile
</:header>
<:panel>
<.live_component module={ProfileForm} id="profile-form" user={@user} />
</:panel>
</.accordion_item>
<.accordion_item expanded={@open_section == :billing}>
<:header phx-click={JS.push("toggle_section", value: %{section: "billing"})}>
Billing
</:header>
<:panel>
<.live_component module={BillingPanel} id="billing" account={@account} />
</:panel>
</.accordion_item>
</.accordion>def handle_event("toggle_section", %{"section" => section}, socket) do
section = String.to_existing_atom(section)
open =
if socket.assigns.open_section == section, do: nil, else: section
{:noreply, assign(socket, :open_section, open)}
endLocal clicks vs server patches
The JavaScript controller updates data-expanded on the clicked item immediately, so the
UI stays responsive even before a server round trip completes. After each LiveView patch
the controller re-reads the server-rendered open state and adopts it, overwriting the
optimistic local state. As long as your assign is the source of truth, the local optimistic
state and the patched state stay consistent.
Examples
An FAQ list with a single section open at a time:
<.accordion>
<.accordion_item expanded>
<:header>How is my data stored?</:header>
<:panel>All data is encrypted at rest and in transit.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Can I export my data?</:header>
<:panel>Yes, from Settings → Account → Export.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Where do I report a bug?</:header>
<:panel>Use the support widget in the bottom-right corner of any page.</:panel>
</.accordion_item>
</.accordion>Settings groups in multiple mode where the user can keep several open while comparing
options. The :panel slot accepts its own class to override the default body padding,
here removing the right padding to give form controls full width:
<.accordion multiple class="rounded-base border border-base">
<.accordion_item class="px-4">
<:header>Notifications</:header>
<:panel class="pr-0 space-y-2">
<.switch name="email_notifications" label="Email" />
<.switch name="push_notifications" label="Push" />
</:panel>
</.accordion_item>
<.accordion_item class="px-4">
<:header>Privacy</:header>
<:panel class="pr-0 space-y-2">
<.switch name="public_profile" label="Public profile" />
<.switch name="show_activity" label="Show activity" />
</:panel>
</.accordion_item>
<.accordion_item class="px-4">
<:header>Integrations</:header>
<:panel>
<.button variant="outline" phx-click="connect_github">Connect GitHub</.button>
</:panel>
</.accordion_item>
</.accordion>Server-driven exclusive expansion using phx-click on the trigger:
<.accordion id="onboarding">
<.accordion_item
:for={step <- @steps}
expanded={@current_step == step.id}
>
<:header phx-click={JS.push("focus_step", value: %{id: step.id})}>
Step {step.number}: {step.title}
</:header>
<:panel>{step.body}</:panel>
</.accordion_item>
</.accordion>Disabled trial item alongside available ones:
<.accordion>
<.accordion_item>
<:header>Free plan</:header>
<:panel>Get started with the basics.</:panel>
</.accordion_item>
<.accordion_item>
<:header>Pro plan</:header>
<:panel>Unlock advanced features.</:panel>
</.accordion_item>
<.accordion_item disabled>
<:header>
<span>Enterprise plan</span>
<.badge color="neutral" class="ml-2">Contact sales</.badge>
</:header>
<:panel>Custom contracts and dedicated support.</:panel>
</.accordion_item>
</.accordion>Reacting to section changes on the server by putting phx-click on each header. Pair it with
an expanded assign so the server both records the interaction and drives which panel is open:
<.accordion id="reports">
<.accordion_item
:for={report <- @reports}
expanded={@active_report == report.id}
>
<:header phx-click={JS.push("select_report", value: %{id: report.id})}>
{report.title}
</:header>
<:panel>{report.summary}</:panel>
</.accordion_item>
</.accordion>def handle_event("select_report", %{"id" => id}, socket) do
active = if socket.assigns.active_report == id, do: nil, else: id
{:noreply, assign(socket, :active_report, active)}
end
Summary
Components
Renders the accordion container.
Wraps a list of accordion_item/1 components, mounts the JavaScript hook that handles
toggling and keyboard navigation, and configures the expansion mode (single vs multiple,
whether all panels can close at once) for every item inside. Required wrapper for any
accordion_item/1; items rendered outside an accordion/1 will not respond to interaction.
Attributes
id(:string) - Identifier for the accordion container. Auto-generated when omitted. The controller scopes its DOM queries to this element, so nested accordions inside an item panel do not interfere with the outer accordion's state machine.class(:any) - Additional CSS classes merged onto the container. Stacked on top of the base styles, which render a vertical stack with dividers between items, so utilities passed here win.Defaults to
nil.multiple(:boolean) - Allows any number of items to be expanded simultaneously. Whenfalse(the default), opening one item closes any other open item. Use for filter panels, settings groups, and other contexts where users want to compare several sections at once.Defaults to
false.prevent_all_closed(:boolean) - Refuses to close the last open item, guaranteeing at least one panel is always expanded. Render at least oneaccordion_itemwithexpandedso the constraint has something to anchor to. Combine withmultiplefor a "one or more" mode that allows several open items but never zero.Defaults to
false.Global attributes are accepted. Additional HTML attributes forwarded to the container element.
Slots
inner_block(required) - One or moreaccordion_item/1components. Other content renders too, but only items participate in keyboard navigation and the open/closed state machine.
Renders one collapsible section inside an accordion/1.
The :header slot is the trigger label and the :panel slot is the collapsible content.
The open/closed state is driven by the expanded boolean, which is re-read on every
LiveView patch. An optional :indicator slot replaces the default chevron.
Attributes
id(:string) - Identifier for this item. Auto-generated when omitted. The id is the key the controller uses to track which items are open. When LiveView patches add or remove items, assigning a stable id (for example one derived from your data) preserves the open set across patches, since ids survive reordering but positions do not.class(:any) - Additional CSS classes merged onto the item wrapper. Useful for per-item borders, backgrounds, or padding that should differ from the rest of the stack.Defaults to
nil.expanded(:boolean) - Controls server-rendered open state and acts as the source of truth re-read on every LiveView patch. Drive this from the same assign that owns the open/closed state in your LiveView (for exampleexpanded={@open_section == :billing}) and the controller will follow whatever the server sends. Client-side toggles update the DOM optimistically; the next server patch reasserts whatever the server sends.Defaults to
false.disabled(:boolean) - Renders the trigger with the nativedisabledattribute, makes the controller ignore click events on it, and skips the item during arrow-key navigation. The panel still renders inside the item, so a server-rendereddisabled expandeditem shows its content but the user cannot collapse it through the UI.Defaults to
false.Global attributes are accepted. Additional HTML attributes forwarded to the item wrapper element.
Slots
header(required) - Content rendered inside the trigger button. Always visible regardless of expansion state. Accepts text, icons, badges, or any inline content; the chevron (or:indicatorslot) sits to the right of this content.Any slot attribute other than
classis forwarded to the underlying<button>. Useful forphx-click,phx-value-*, customdata-*hooks, or extra ARIA properties on the trigger.class(string): merged onto the trigger button after the base styles.
panel(required) - Collapsible content shown when the item is expanded. Always present in the DOM (so descendant LiveComponent state survives toggling); visibility is animated via a CSSgrid-template-rowstransition.class(string): merged onto the inner content wrapper after the base padding/text styles. Use this to override the default padding (pr-8 pb-4), text color, or layout of the panel body.
Accepts attributes:
class(:any)
indicator- Replacement for the default chevron. Renders to the right of the header label inside the trigger. The item wrapper carriesdata-expandedwhen open, so any expand/collapse visual can be driven withgroup-data-expanded/accordion-item:Tailwind variants on the slot content. Omit the slot to keep the default rotating chevron, or pass an empty slot body to suppress the chevron entirely.