Vertical stack of collapsible sections with single or multi-open semantics, built on the @foldkit/ui Disclosure helper.

Preview
Style
Basic
Multiple
With Borders
We offer monthly and annual subscription plans. Billing is charged at the beginning of each cycle, and you can cancel anytime.
In Card
Subscription & Billing
Common questions about your account, plans, and payments
We offer three subscription tiers: Starter ($9/month), Professional ($29/month), and Enterprise ($99/month).
With Disabled
src/demo/views/accordion.ts347 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as accordion from '../../generated/registry/ui/accordion'
import { Card } from '../../generated/registry/ui/card'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotAccordionBasicMessage: { message: accordion.Message },
  GotAccordionMultipleMessage: { message: accordion.Message },
  GotAccordionBordersMessage: { message: accordion.Message },
  GotAccordionInCardMessage: { message: accordion.Message },
  GotAccordionDisabledMessage: { message: accordion.Message },
})

const BASIC_ITEMS = [
  {
    id: 'acc-basic-1',
    title: 'Is it accessible?',
    content: 'Yes. It adheres to the WAI-ARIA design pattern.',
  },
  {
    id: 'acc-basic-2',
    title: 'Is it styled?',
    content: "Yes. It comes with default styles that matches the other components' aesthetic.",
  },
  {
    id: 'acc-basic-3',
    title: 'Is it animated?',
    content: "Yes. It's animated by default, but you can disable it if you prefer.",
  },
] as const

const MULTIPLE_ITEMS = [
  {
    id: 'acc-multi-1',
    title:
      'What are the key considerations when implementing a comprehensive enterprise-level authentication system?',
    content:
      'Implementing a robust enterprise authentication system requires careful consideration of multiple factors. This includes secure password hashing and storage, multi-factor authentication (MFA) implementation, session management, OAuth2 and SSO integration, regular security audits, rate limiting to prevent brute force attacks, and maintaining detailed audit logs.',
  },
  {
    id: 'acc-multi-2',
    title:
      'How does modern distributed system architecture handle eventual consistency and data synchronization across multiple regions?',
    content:
      'Modern distributed systems employ various strategies to maintain data consistency across regions. This often involves using techniques like CRDT (Conflict-Free Replicated Data Types), vector clocks, and gossip protocols.',
  },
] as const

const BORDERS_ITEMS = [
  {
    id: 'acc-borders-billing',
    title: 'How does billing work?',
    content:
      'We offer monthly and annual subscription plans. Billing is charged at the beginning of each cycle, and you can cancel anytime.',
  },
  {
    id: 'acc-borders-security',
    title: 'Is my data secure?',
    content:
      'Yes. We use end-to-end encryption, SOC 2 Type II compliance, and regular third-party security audits.',
  },
  {
    id: 'acc-borders-integration',
    title: 'What integrations do you support?',
    content:
      'We integrate with 500+ popular tools including Slack, Zapier, Salesforce, HubSpot, and more.',
  },
] as const

const IN_CARD_ITEMS = [
  {
    id: 'acc-card-plans',
    title: 'What subscription plans do you offer?',
    content:
      'We offer three subscription tiers: Starter ($9/month), Professional ($29/month), and Enterprise ($99/month).',
  },
  {
    id: 'acc-card-billing',
    title: 'How does billing work?',
    content:
      'Billing occurs automatically at the start of each billing cycle. We accept all major credit cards, PayPal, and ACH transfers.',
  },
  {
    id: 'acc-card-upgrade',
    title: 'Can I upgrade or downgrade my plan?',
    content:
      'Yes, you can change your plan at any time. When upgrading, you will be charged a prorated amount.',
  },
] as const

const DISABLED_ITEMS = [
  {
    id: 'acc-dis-1',
    title: 'Can I access my account history?',
    content: 'Yes, you can view your complete account history including all transactions.',
    isDisabled: false,
  },
  {
    id: 'acc-dis-2',
    title: 'Premium feature information',
    content:
      'This section contains information about premium features. Upgrade your plan to access this content.',
    isDisabled: true,
  },
  {
    id: 'acc-dis-3',
    title: 'How do I update my email address?',
    content:
      "You can update your email address in your account settings. You'll receive a verification email.",
    isDisabled: false,
  },
] as const

export const accordionView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.accordionBasic.id,
            model: model.accordionBasic,
            view: accordion.view,
            viewInputs: { className: 'mx-auto max-w-lg', items: [...BASIC_ITEMS] },
            toParentMessage: (message) => Message.GotAccordionBasicMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Multiple']),
          h.submodel({
            slotId: model.accordionMultiple.id,
            model: model.accordionMultiple,
            view: accordion.view,
            viewInputs: { className: 'mx-auto max-w-lg', items: [...MULTIPLE_ITEMS] },
            toParentMessage: (message) => Message.GotAccordionMultipleMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Borders']),
          h.submodel({
            slotId: model.accordionBorders.id,
            model: model.accordionBorders,
            view: accordion.view,
            viewInputs: {
              className: 'mx-auto max-w-lg',
              items: BORDERS_ITEMS.map((it) => ({
                id: it.id,
                title: it.title,
                content: it.content,
              })),
            },
            toParentMessage: (message) => Message.GotAccordionBordersMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['In Card']),
          Card<AppMessage>(
            { className: 'mx-auto w-full max-w-lg gap-4' },
            [
              Card.header<AppMessage>(
                {},
                [
                  Card.title<AppMessage>({}, ['Subscription & Billing'], h),
                  Card.description<AppMessage>(
                    {},
                    ['Common questions about your account, plans, and payments'],
                    h,
                  ),
                ],
                h,
              ),
              Card.content<AppMessage>(
                {},
                [
                  h.submodel({
                    slotId: model.accordionInCard.id,
                    model: model.accordionInCard,
                    view: accordion.view,
                    viewInputs: { items: [...IN_CARD_ITEMS] },
                    toParentMessage: (message) => Message.GotAccordionInCardMessage({ message }),
                  }),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Disabled']),
          h.submodel({
            slotId: model.accordionDisabled.id,
            model: model.accordionDisabled,
            view: accordion.view,
            viewInputs: {
              className: 'mx-auto max-w-lg overflow-hidden border',
              items: DISABLED_ITEMS.map((it) => ({
                id: it.id,
                title: it.title,
                content: it.content,
                isDisabled: it.isDisabled,
              })),
            },
            toParentMessage: (message) => Message.GotAccordionDisabledMessage({ message }),
          }),
        ],
      ),
    ],
  )

const foldNoOp =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldOut = M.type<accordion.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({ ChangedValue: foldNoOp() }),
)

const makeFold = (
  read: (m: State) => Option.Option<accordion.Model>,
  write: (m: State, n: accordion.Model) => State,
  toParent: (m: accordion.Message) => AppMessage,
) =>
  Update.foldChild({
    update: accordion.update,
    read: (model: State): Option.Option<accordion.Model> => read(model),
    write,
    toParentMessage: toParent,
    foldOutMessage: foldOut,
  })

const folds = {
  basic: makeFold(
    (m) => Option.some(m.accordionBasic),
    (m, n) => evo(m, { accordionBasic: () => n }),
    (msg) => Message.GotAccordionBasicMessage({ message: msg }),
  ),
  multiple: makeFold(
    (m) => Option.some(m.accordionMultiple),
    (m, n) => evo(m, { accordionMultiple: () => n }),
    (msg) => Message.GotAccordionMultipleMessage({ message: msg }),
  ),
  borders: makeFold(
    (m) => Option.some(m.accordionBorders),
    (m, n) => evo(m, { accordionBorders: () => n }),
    (msg) => Message.GotAccordionBordersMessage({ message: msg }),
  ),
  inCard: makeFold(
    (m) => Option.some(m.accordionInCard),
    (m, n) => evo(m, { accordionInCard: () => n }),
    (msg) => Message.GotAccordionInCardMessage({ message: msg }),
  ),
  disabled: makeFold(
    (m) => Option.some(m.accordionDisabled),
    (m, n) => evo(m, { accordionDisabled: () => n }),
    (msg) => Message.GotAccordionDisabledMessage({ message: msg }),
  ),
}

const fields = {
  accordionBasic: accordion.Model,
  accordionMultiple: accordion.Model,
  accordionBorders: accordion.Model,
  accordionInCard: accordion.Model,
  accordionDisabled: accordion.Model,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    accordionBasic: accordion.init({
      id: 'accordion-basic',
      type: 'single',
      value: [false, false, false],
    }),
    accordionMultiple: accordion.init({
      id: 'accordion-multiple',
      type: 'multiple',
      value: [false, false],
    }),
    accordionBorders: accordion.init({
      id: 'accordion-borders',
      type: 'single',
      value: [true, false, false],
    }),
    accordionInCard: accordion.init({
      id: 'accordion-incard',
      type: 'multiple',
      value: [true, false, false],
    }),
    accordionDisabled: accordion.init({
      id: 'accordion-disabled',
      type: 'single',
      value: [false, false, false],
    }),
  },
  messages: [
    Message.GotAccordionBasicMessage,
    Message.GotAccordionMultipleMessage,
    Message.GotAccordionBordersMessage,
    Message.GotAccordionInCardMessage,
    Message.GotAccordionDisabledMessage,
  ],
  handlers: (model: State) => ({
    GotAccordionBasicMessage: (p: typeof Message.GotAccordionBasicMessage.Type): UpdateReturn =>
      folds.basic(model, p.message),
    GotAccordionMultipleMessage: (
      p: typeof Message.GotAccordionMultipleMessage.Type,
    ): UpdateReturn => folds.multiple(model, p.message),
    GotAccordionBordersMessage: (p: typeof Message.GotAccordionBordersMessage.Type): UpdateReturn =>
      folds.borders(model, p.message),
    GotAccordionInCardMessage: (p: typeof Message.GotAccordionInCardMessage.Type): UpdateReturn =>
      folds.inCard(model, p.message),
    GotAccordionDisabledMessage: (
      p: typeof Message.GotAccordionDisabledMessage.Type,
    ): UpdateReturn => folds.disabled(model, p.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/accordion

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/accordion.ts240 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Accordion from '@/components/ui/accordion'`
 */
import { Function, Schema as S } from 'effect'
import { Disclosure as FoldkitDisclosure } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import type { Reflect } from 'foldkit/submodel'
import { defineView } from 'foldkit/submodel'
import { evo } from 'foldkit/struct'
import * as Update from 'foldkit/update'

type Child = Html | string

import { icon } from '@/lib/icons'
import { ChevronDown } from 'lucide'
import { cn } from '@/lib/utils'

// An accordion is a vertical stack of Disclosure items with shared single or
// multi-open semantics, owned by this Submodel: embed it with `h.submodel`
// and listen for `ChangedValue` to lift open-state changes into your own
// model. Conform an externally-driven open-state array (URL, storage) with
// `reflect`.
//
// foldcn gaps vs upstream: the chevron rotates instead of swapping icons.

export const buttonId = FoldkitDisclosure.buttonId

export const accordionWrapperClass = 'flex w-full flex-col'

export const accordionItemClass = 'not-last:border-b'

export const accordionTriggerClass =
  'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50'

export const accordionContentClass = 'data-enter:animate-accordion-down data-leave:animate-accordion-up text-sm overflow-hidden'

export const accordionAnimatedContentClass = 'data-enter:animate-accordion-down data-leave:animate-accordion-up text-sm overflow-hidden'

export const accordionContentInnerClass =
  'pt-0 pb-2.5 h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4'

export const accordionChevronClass =
  'pointer-events-none size-4 shrink-0 transition-transform group-aria-expanded/accordion-trigger:rotate-180'

export const Type = S.Literals(['single', 'multiple'])
export type AccordionType = typeof Type.Type

/** Computes the next open-state array for an accordion group. Pads the array
 *  so indexes beyond the current length are addressable. */
export const nextAccordionOpen = (
  current: ReadonlyArray<boolean>,
  index: number,
  isOpen: boolean,
  type: AccordionType = 'multiple',
): ReadonlyArray<boolean> => {
  const padded = Array.from(
    { length: Math.max(current.length, index + 1) },
    (_, itemIndex) => current[itemIndex] ?? false,
  )
  return type === 'single' && isOpen
    ? padded.map((_, itemIndex) => itemIndex === index)
    : padded.map((value, itemIndex) => (itemIndex === index ? isOpen : value))
}

// MODEL

export const Model = S.Struct({
  id: S.String,
  type: Type,
  /** Per-item open state, indexed like the view's items array. */
  value: S.Array(S.Boolean),
})
export type Model = typeof Model.Type

// MESSAGES

/** The user clicked an item's trigger. Flips that item's open state — closing
 *  every other item first when `type` is `single`. */
export const Message = defineMessageUnion({
  ToggledItem: { index: S.Number, isOpen: S.Boolean },
})
export type Message = typeof Message.Type

/** Emitted when the open-state array changes. */
export const OutMessage = defineMessageUnion({
  ChangedValue: { value: S.Array(S.Boolean) },
})
export type OutMessage = typeof OutMessage.Type

// INIT / UPDATE

export type InitConfig = Readonly<{
  id: string
  type?: AccordionType
  value?: ReadonlyArray<boolean>
}>

/** Creates an initial accordion model. */
export const init = (config: InitConfig): Model => ({
  id: config.id,
  type: config.type ?? 'multiple',
  value: config.value === undefined ? [] : [...config.value],
})

/** Conforms an externally-driven open-state array onto the model without
 *  emitting an OutMessage (the world is the source of truth). */
export const reflect: Reflect<Model, ReadonlyArray<boolean>> = Function.dual(
  2,
  (model: Model, value: ReadonlyArray<boolean>): Model => evo(model, { value: () => [...value] }),
)

type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>

/** Processes an accordion message and returns the next model, commands, and
 *  an optional out-message for the parent. */
export const update = (model: Model, message: Message): UpdateReturn => {
  switch (message._tag) {
    case 'ToggledItem': {
      const value = nextAccordionOpen(model.value, message.index, message.isOpen, model.type)
      return {
        model: evo(model, { value: () => [...value] }),
        outMessage: OutMessage.ChangedValue({ value }),
      }
    }
  }
}

// VIEW

export type AccordionItemViewInput = Readonly<{
  id: string
  title: Child
  content: Child
  isDisabled?: boolean
  ariaLabel?: string
  ariaLabelledBy?: string
  isAnimated?: boolean
  triggerClass?: string
  contentClass?: string
  wrapperClass?: string
}>

export type ViewInputs = Readonly<{
  items: ReadonlyArray<AccordionItemViewInput>
  className?: string
}>

/** Renders the controlled accordion group. Embedded via `h.submodel`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) =>
  h.div(
    [
      h.Class(cn(accordionWrapperClass, viewInputs.className)),
      h.DataAttribute('slot', 'accordion'),
    ],
    viewInputs.items.map((item, index) =>
      accordionItem(
        {
          ...item,
          isOpen: model.value[index] ?? false,
          onToggle: (isOpen) => Message.ToggledItem({ index, isOpen }),
        },
        h,
      ),
    ),
  ),
)

const accordionItem = (
  config: AccordionItemViewInput & {
    isOpen: boolean
    onToggle: (isOpen: boolean) => Message
  },
  h: HtmlBuilder<Message>,
): Html =>
  FoldkitDisclosure.view(
    {
      id: config.id,
      isOpen: config.isOpen,
      onToggle: config.onToggle,
      isDisabled: config.isDisabled,
      ariaLabel: config.ariaLabel,
      ariaLabelledBy: config.ariaLabelledBy,
      toView: ({ button, panel, animatePanel }) =>
        h.div(
          [
            h.Class(cn(accordionItemClass, config.wrapperClass)),
            h.DataAttribute('slot', 'accordion-item'),
          ],
          [
            h.div(
              [h.Class('flex'), h.DataAttribute('slot', 'accordion-header')],
              [
                h.button(
                  [
                    ...button,
                    h.Class(cn(accordionTriggerClass, config.triggerClass)),
                    h.DataAttribute('slot', 'accordion-trigger'),
                  ],
                  [
                    h.span([], [config.title]),
                    h.span(
                      [
                        h.Class(accordionChevronClass),
                        h.DataAttribute('slot', 'accordion-trigger-icon'),
                      ],
                      [icon(h, ChevronDown)],
                    ),
                  ],
                ),
              ],
            ),
            config.isAnimated === true
              ? animatePanel(
                  h.div(
                    [
                      ...panel,
                      h.Class(cn(accordionAnimatedContentClass, config.contentClass)),
                      h.DataAttribute('slot', 'accordion-content'),
                    ],
                    [h.div([h.Class(accordionContentInnerClass)], [config.content])],
                  ),
                )
              : config.isOpen
                ? h.div(
                    [
                      ...panel,
                      h.Class(cn(accordionContentClass, config.contentClass)),
                      h.DataAttribute('slot', 'accordion-content'),
                    ],
                    [h.div([h.Class(accordionContentInnerClass)], [config.content])],
                  )
                : h.empty,
          ],
        ),
    },
    h,
  )