Accordion

Vertically stacked sections that expand and collapse to reveal their content.

import { ... } from '@corndev/ui-overlays'
A lightweight, framework-agnostic component library built by Corndog Development.

HTML

<div class="corn-accordion">
  <div class="corn-accordion-item">
    <button class="corn-accordion-trigger" data-accordion-item="one" data-accordion-trigger aria-expanded="false">
      Section one
    </button>
    <div class="corn-accordion-panel" data-accordion-item="one" data-accordion-panel hidden>Panel body</div>
  </div>
  <div class="corn-accordion-item">
    <button class="corn-accordion-trigger" data-accordion-item="two" data-accordion-trigger aria-expanded="false">
      Section two
    </button>
    <div class="corn-accordion-panel" data-accordion-item="two" data-accordion-panel hidden>Panel body</div>
  </div>
</div>

Classes

Class
.corn-accordion
.corn-accordion-trigger
.corn-accordion-item
.corn-accordion-panel

Attributes

AttributeValues
data-openpresent or absent

Vanilla JS

Required for behaviour — adds toggles panels on click and maintains aria-expanded.

import { createAccordion } from '@corndev/ui-overlays';
import '@corndev/ui/theme';

const containerEl = document.querySelector('#target');
const instance = createAccordion(containerEl, { /* options */ });

Options

OptionTypeDefaultDescription
multiplebooleanfalseAllow multiple panels open at once
defaultOpenstring|string[]undefinedItem key(s) open on init
onExpand(panel: HTMLElement, key: string) => voidundefinedCalled the first time each panel opens, with the panel element, before onChange. Ship a collapsed section with an empty panel and fill it here. This is what makes a large document survivable: a 283-path OpenAPI reference built 112,308 elements and 16MB of markup when every closed panel was constructed up front, and froze the window. Once per key, not once per open — reopening a branch must not rebuild it. Same contract as createNavigation’s onExpand(item) and createTreeState’s, so one “render these children” function serves navigation, tree and content alike.
onChangeFunctionundefinedCalled with (key, isOpen) on toggle, after onExpand, so a listener sees a panel that is already filled.

React

Accordion, AccordionItem — from @corndev/ui-overlays-react.

Passing value makes the accordion controlled; omit it and use defaultOpen for uncontrolled. React onChange receives the new open value(s) — a string[] when multiple, otherwise a string or null — not the (key, isOpen) pair the vanilla version passes. An Accordion.Item whose children are a function defers building the panel body until that panel is open, and re-builds on each render while it stays open; element children are built where they are written, as before. The vanilla component has no equivalent because it enhances markup that already exists — build on demand from its onChange instead. Only direct <Accordion.Item> children are read. Accordion builds the triggers and panels from their props, so anything else — including an Accordion.Item inside a wrapper element — is dropped; it warns in development rather than silently vanishing. Trigger and panel ids are minted per instance, so several accordions on a page may reuse the same item values without their aria-controls crossing over.

Props

PropTypeDefaultDescription
multiplebooleanfalseAllow multiple panels open at once
defaultOpenstring|string[]undefinedInitially open item(s) (uncontrolled)
valuestring|string[]undefinedControlled open item(s)
onChangeFunctionundefinedCalled with new open value(s) on change
classNamestringundefined
childrenReact.ReactNoderequiredAccordionItem elements

<Accordion.Item> props

One section. Also exported as AccordionItem. Never rendered directly — Accordion reads its props.

PropTypeDefaultDescription
valuestringrequiredIdentifier, matched against defaultOpen/value.
labelReact.ReactNoderequiredTrigger content.
disabledbooleanfalsePrevents the panel from opening.
childrenReact.ReactNode|(() => React.ReactNode)undefinedPanel body. A function is called only while the panel is open, so a closed panel builds nothing; anything else renders as ordinary children, built whether the panel is open or not.

React usage

import { Accordion, AccordionItem } from '@corndev/ui-overlays-react';
import '@corndev/ui-overlays/styles';

<Accordion defaultOpen="item1">
  <AccordionItem value="item1" label="What is Corn UI?">
    A lightweight, framework-agnostic component library.
  </AccordionItem>
  <AccordionItem value="item2" label="Is it free?">
    Yes, it's MIT licensed.
  </AccordionItem>
  <AccordionItem value="item3" label="Does it support React?">
    Yes — via the @corndev/ui-react and @corndev/ui-overlays-react packages.
  </AccordionItem>
</Accordion>

Multiple open panels

<Accordion multiple defaultOpen={['item1', 'item2']}>
  <AccordionItem value="item1" label="Section A">Content A</AccordionItem>
  <AccordionItem value="item2" label="Section B">Content B</AccordionItem>
  <AccordionItem value="item3" label="Section C">Content C</AccordionItem>
</Accordion>

Controlled

const [open, setOpen] = useState('item1');

<Accordion value={open} onChange={setOpen}>
  <AccordionItem value="item1" label="Section A">Content A</AccordionItem>
  <AccordionItem value="item2" label="Section B">Content B</AccordionItem>
</Accordion>

Disabled item

<Accordion>
  <AccordionItem value="item1" label="Active">Content</AccordionItem>
  <AccordionItem value="item2" label="Disabled" disabled>Locked</AccordionItem>
</Accordion>

Expensive panels: defer the body

A panel’s children are built where you write them, before Accordion ever runs. Hiding a closed panel therefore saves painting it, not building it — with twenty closed sections you have still constructed twenty subtrees.

Pass a function instead and it is called only while that panel is open:

<Accordion>
  {/* built now, whether or not this panel ever opens */}
  <AccordionItem value="eager" label="Overview">
    <SchemaTable schema={schema} />
  </AccordionItem>

  {/* built when this panel opens, and not before */}
  <AccordionItem value="lazy" label="Response schema">
    {() => <SchemaTable schema={schema} />}
  </AccordionItem>
</Accordion>

The difference is easy to miss and there is no error if you get it wrong:

{() => <SchemaTable />}   {/* a function — deferred */}
{<SchemaTable />}         {/* an element — built immediately */}

Element children keep working exactly as before, so nothing needs changing until a panel is worth deferring. The function is called on each render while the panel is open — like any render prop — rather than being cached on first open, so the body always sees current state. Closing the panel drops it; re-opening calls it again.

Nested accordions work without any extra wiring: an accordion inside a panel is ordinary content, and each Accordion only ever reads its own direct Accordion.Item children.

Why the vanilla component has no equivalent

createAccordion enhances markup that is already in the document — it finds [data-accordion-trigger] and [data-accordion-panel] elements and wires them together. By the time it runs, the panel content exists, so there is nothing left to defer. If you want a vanilla panel built on demand, leave it empty and fill it in the onChange callback:

const accordion = createAccordion(el, {
  onChange(key, isOpen) {
    const panel = el.querySelector(`[data-accordion-panel][data-accordion-item="${key}"]`);
    if (isOpen && !panel.dataset.filled) {
      panel.append(buildBody(key));
      panel.dataset.filled = 'true';
    }
  },
});