Skip to main content

Accordion

Coordinate one or several expandable sections with shared disclosure rules.

Use Accordion for several related disclosures that share one open-item policy. For a single independent disclosure, use Collapsible.

Basic usage#

import { Accordion } from 'moraine'

export function Example() {
  return (
    <Accordion items={[{ value: 'shipping', label: 'Shipping', content: 'Ships in two days.' }]} />
  )
}

Playground#

Install moraine and configure either UnoCSS or Tailwind CSS before rendering components.

Props
Slots

Anatomy#

Accordion [component; slot=root]
└── item [slot]
    ├── header [slot]
    │   └── trigger [slot]
    │       ├── leading [slot]
    │       ├── label [slot]
    │       └── trailing [slot]
    └── content [slot]
        └── body [slot]

Usage#

Open items#

Use an array of values for value, defaultValue, and onChange in both modes. Single mode opens at most one item; set multiple to allow several. In controlled usage, pair value with onChange. collapsible determines whether the currently open item may be closed.

import { Accordion } from 'moraine'
import { createSignal } from 'solid-js'

const FAQ_ITEMS = [
  {
    value: 'q1',
    label: 'Is Moraine SSR-compatible?',
    content: 'Yes, Moraine is designed for full SolidStart SSR and SSG hydration safety.',
  },
  {
    value: 'q2',
    label: 'How does styling work?',
    content:
      'Moraine uses atomic class styling with official presets and plugins for UnoCSS and Tailwind CSS.',
  },
]

export function OpenItems() {
  const [active, setActive] = createSignal<string[]>(['q1'])

  return (
    <div class="max-w-md w-full space-y-3">
      <Accordion collapsible items={FAQ_ITEMS} value={active()} onChange={setActive} />
      <p class="text-xs text-muted-foreground">
        Open item: <span class="text-foreground font-mono">{active()[0] ?? 'none'}</span>
      </p>
    </div>
  )
}

Content lifetime#

The content shell stays mounted so each trigger’s aria-controls always points to a real element. unmountOnHide controls only the user content inside that shell. Choose based on whether hidden controls need to preserve local state.

import { Accordion, Input } from 'moraine'

export function ContentLifetime() {
  return (
    <div class="max-w-md w-full">
      <Accordion
        collapsible
        defaultValue={['settings']}
        items={[
          {
            value: 'settings',
            label: 'Persistent form state',
            content: (
              <div class="py-2 space-y-2">
                <p class="text-xs text-muted-foreground">
                  Inputs retain their state across open/close toggles.
                </p>
                <Input placeholder="Preserved input text" defaultValue="Draft notes" />
              </div>
            ),
          },
        ]}
      />
    </div>
  )
}

Keyboard interaction#

Key Description
↵ / Space Toggles the focused trigger when enabled.
↓ Moves focus to the next enabled trigger.
↑ Moves focus to the previous enabled trigger.
↖ Moves focus to the first enabled trigger.
↘ Moves focus to the last enabled trigger.

Examples#

Single item#

import { Accordion } from 'moraine'
import { createSignal } from 'solid-js'

export function Single() {
  const [openValue, setOpenValue] = createSignal<string[]>(['invite'])

  return (
    <div class="max-w-xl w-full space-y-3">
      <Accordion
        value={openValue()}
        onChange={setOpenValue}
        items={[
          {
            value: 'invite',
            label: 'What should I check before inviting a member?',
            leading: 'i-lucide:user-plus',
            content:
              'Confirm the email address and choose the role that gives the person only the access they need.',
          },
          {
            value: 'archive',
            label: 'When should I archive a project?',
            leading: 'i-lucide:archive',
            content:
              'Archive work that no longer needs active updates after your team has saved any reports it still needs.',
          },
          {
            value: 'status',
            label: 'Where should a release status be recorded?',
            leading: 'i-lucide:list-checks',
            content:
              'Keep the status on the release record so everyone sees the same source of truth.',
          },
        ]}
      />

      <p class="text-xs text-muted-foreground">
        Active section: <span class="text-foreground font-medium">{openValue()[0] ?? 'none'}</span>
      </p>
    </div>
  )
}

Multiple items#

import { Accordion } from 'moraine'

export function Multiple() {
  return (
    <Accordion
      multiple
      defaultValue={['general', 'security']}
      items={[
        {
          value: 'general',
          label: 'General Workspace Information',
          leading: 'i-lucide:settings',
          content: 'Configure your organization name, slug, avatar, and default language settings.',
        },
        {
          value: 'security',
          label: 'Security & Access Control',
          leading: 'i-lucide:shield-alert',
          content:
            'Manage SSO providers, enforce two-factor authentication, and review active sessions.',
        },
        {
          value: 'billing',
          label: 'Subscription & Invoices',
          leading: 'i-lucide:credit-card',
          content:
            'View current plan usage, payment methods, and download historical invoice receipts.',
        },
      ]}
      classes={{
        root: 'max-w-xl rounded-xl b-1 b-border border-border bg-card',
        trigger: 'px-4',
        content: 'px-4 text-foreground text-sm',
      }}
    />
  )
}

Disabled and custom content#

import { Accordion } from 'moraine'

export function DisabledCustomContent() {
  return (
    <Accordion
      defaultValue={['setup']}
      trailing="icon-plus"
      items={[
        {
          value: 'setup',
          label: 'Setup checklist',
          leading: 'i-lucide-list-checks',
          content: (
            <div class="space-y-2">
              <p>Complete these steps before inviting your team:</p>
              <ul class="pl-5 list-disc space-y-1">
                <li>Create workspace profile</li>
                <li>Configure authentication</li>
                <li>Enable notifications</li>
              </ul>
              <div class="text-xs text-muted-foreground p-2 rounded-md bg-muted">
                Tip: You can finish the checklist later from Settings.
              </div>
            </div>
          ),
        },
        {
          value: 'security',
          label: 'Security review (Locked)',
          leading: 'i-lucide-shield-check',
          disabled: true,
          content: 'Available on Pro plan and above.',
        },
        {
          value: 'integrations',
          label: 'Integrations',
          leading: 'i-lucide-plug',
          content: (
            <div class="pt-2 space-y-2">
              <p>Connect your tools to automate the workflow.</p>
              <p>
                Supported: <strong>GitHub</strong>, <strong>Slack</strong>, and{' '}
                <strong>Notion</strong>.
              </p>
            </div>
          ),
        },
      ]}
      classes={{
        root: 'max-w-xl rounded-lg b-1 b-border border-border bg-background',
        trigger: 'px-3',
        content: 'px-4 text-foreground',
      }}
    />
  )
}

Attributes#

Attributes
data-disabledSlot: accordion, accordion-item, accordion-triggerDescription: Present when the component, slot, or item is disabled.
data-closedSlot: accordion-item, accordion-trigger, accordion-contentDescription: Present when disclosure or transition content is closed.
data-expandedSlot: accordion-item, accordion-trigger, accordion-trailing, accordion-contentDescription: Present when the panel, accordion, or menu is expanded.

Props#

Props for the Accordion component. Renders a <div> element by default.

Prop