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 :indicator slot replaces the default chevron.

A typical structure looks like this:

accordion
 accordion_item
    :header slot (button label)
    :panel slot (collapsible content)
 accordion_item
 accordion_item

Choosing 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.

AttributeBehavior
(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.
multipleAny 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_closedAt 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)}
end

Local 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 &rarr; Account &rarr; 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.

Renders one collapsible section inside an accordion/1.

Components

accordion(assigns)

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. When false (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 one accordion_item with expanded so the constraint has something to anchor to. Combine with multiple for 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 more accordion_item/1 components. Other content renders too, but only items participate in keyboard navigation and the open/closed state machine.

accordion_item(assigns)

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 example expanded={@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 native disabled attribute, 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-rendered disabled expanded item 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 :indicator slot) sits to the right of this content.

    Any slot attribute other than class is forwarded to the underlying <button>. Useful for phx-click, phx-value-*, custom data-* 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 CSS grid-template-rows transition.

    • 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 carries data-expanded when open, so any expand/collapse visual can be driven with group-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.