Stateful single-section collapsible panel, built on the @foldkit/ui Disclosure helper.

Preview
Style
File Tree
Explorer
button.tsx
card.tsx
dialog.tsx
app.tsx
package.json
Settings
Radius
Set the corner radius of the element.
Single
src/demo/views/collapsible.ts255 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 collapsible from '../../generated/registry/ui/collapsible'
import { Card } from '../../generated/registry/ui/card'
import { icon } from '../../generated/registry/lib/icons'
import { ChevronRight, File as FileIcon, Folder } from 'lucide'

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

const Message = defineMessageUnion({
  GotCollapsibleMessage: { message: collapsible.Message },
  GotCollapsibleComponentsMessage: { message: collapsible.Message },
  GotCollapsibleLibMessage: { message: collapsible.Message },
  GotCollapsibleSettingsMessage: { message: collapsible.Message },
})

const fileLeaf = (name: string, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm')],
    [icon(h, FileIcon, 'size-4'), h.span([], [name])],
  )

export const collapsibleView = (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')], ['File Tree']),
          Card<AppMessage>(
            { className: 'mx-auto w-full max-w-sm gap-2' },
            [
              Card.header<AppMessage>(
                {},
                [h.div([h.Class('text-sm font-medium')], ['Explorer'])],
                h,
              ),
              Card.content<AppMessage>(
                {},
                [
                  h.div(
                    [h.Class('flex flex-col gap-1')],
                    [
                      h.submodel({
                        slotId: model.collapsibleComponents.id,
                        model: model.collapsibleComponents,
                        view: collapsible.view,
                        viewInputs: {
                          title: h.span(
                            [h.Class('inline-flex items-center gap-1.5')],
                            [
                              icon(h, ChevronRight, 'size-3.5'),
                              icon(h, Folder, 'size-4'),
                              'components',
                            ],
                          ),
                          content: h.div(
                            [h.Class('ml-5 flex flex-col gap-1')],
                            [
                              fileLeaf('button.tsx', h),
                              fileLeaf('card.tsx', h),
                              fileLeaf('dialog.tsx', h),
                            ],
                          ),
                        },
                        toParentMessage: (message) =>
                          Message.GotCollapsibleComponentsMessage({ message }),
                      }),
                      h.submodel({
                        slotId: model.collapsibleLib.id,
                        model: model.collapsibleLib,
                        view: collapsible.view,
                        viewInputs: {
                          title: h.span(
                            [h.Class('inline-flex items-center gap-1.5')],
                            [icon(h, ChevronRight, 'size-3.5'), icon(h, Folder, 'size-4'), 'lib'],
                          ),
                          content: h.div(
                            [h.Class('ml-5 flex flex-col gap-1')],
                            [fileLeaf('utils.ts', h), fileLeaf('cn.ts', h)],
                          ),
                        },
                        toParentMessage: (message) => Message.GotCollapsibleLibMessage({ message }),
                      }),
                      fileLeaf('app.tsx', h),
                      fileLeaf('package.json', h),
                    ],
                  ),
                ],
                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')], ['Settings']),
          Card<AppMessage>(
            { className: 'mx-auto w-full max-w-xs' },
            [
              Card.header<AppMessage>(
                {},
                [
                  Card.title<AppMessage>({}, ['Radius'], h),
                  Card.description<AppMessage>({}, ['Set the corner radius of the element.'], h),
                ],
                h,
              ),
              Card.content<AppMessage>(
                {},
                [
                  h.submodel({
                    slotId: model.collapsibleSettings.id,
                    model: model.collapsibleSettings,
                    view: collapsible.view,
                    viewInputs: {
                      title: 'Order #4189 - Shipped',
                      content:
                        'Shipping address: 100 Market St, San Francisco - Items: 2x Studio Headphones',
                    },
                    toParentMessage: (message) =>
                      Message.GotCollapsibleSettingsMessage({ 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')], ['Single']),
          h.div(
            [h.Class('flex w-[350px] flex-col gap-2')],
            [
              h.submodel({
                slotId: model.collapsible.id,
                model: model.collapsible,
                view: collapsible.view,
                viewInputs: {
                  title: 'Order #4189 - Shipped',
                  content:
                    'Shipping address: 100 Market St, San Francisco - Items: 2x Studio Headphones',
                },
                toParentMessage: (message) => Message.GotCollapsibleMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

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

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

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

const folds = {
  main: makeFold(
    (m) => Option.some(m.collapsible),
    (m, n) => evo(m, { collapsible: () => n }),
    (msg) => Message.GotCollapsibleMessage({ message: msg }),
  ),
  components: makeFold(
    (m) => Option.some(m.collapsibleComponents),
    (m, n) => evo(m, { collapsibleComponents: () => n }),
    (msg) => Message.GotCollapsibleComponentsMessage({ message: msg }),
  ),
  lib: makeFold(
    (m) => Option.some(m.collapsibleLib),
    (m, n) => evo(m, { collapsibleLib: () => n }),
    (msg) => Message.GotCollapsibleLibMessage({ message: msg }),
  ),
  settings: makeFold(
    (m) => Option.some(m.collapsibleSettings),
    (m, n) => evo(m, { collapsibleSettings: () => n }),
    (msg) => Message.GotCollapsibleSettingsMessage({ message: msg }),
  ),
}

const fields = {
  collapsible: collapsible.Model,
  collapsibleComponents: collapsible.Model,
  collapsibleLib: collapsible.Model,
  collapsibleSettings: collapsible.Model,
}

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

export const slice = defineSlice({
  fields,
  init: {
    collapsible: collapsible.init({ id: 'collapsible-demo', isAnimated: true }),
    collapsibleComponents: collapsible.init({
      id: 'collapsible-components',
      isAnimated: true,
      isOpen: true,
    }),
    collapsibleLib: collapsible.init({ id: 'collapsible-lib', isAnimated: true }),
    collapsibleSettings: collapsible.init({ id: 'collapsible-settings', isAnimated: true }),
  },
  messages: [
    Message.GotCollapsibleMessage,
    Message.GotCollapsibleComponentsMessage,
    Message.GotCollapsibleLibMessage,
    Message.GotCollapsibleSettingsMessage,
  ],
  handlers: (model: State) => ({
    GotCollapsibleMessage: (p: typeof Message.GotCollapsibleMessage.Type): UpdateReturn =>
      folds.main(model, p.message),
    GotCollapsibleComponentsMessage: (
      p: typeof Message.GotCollapsibleComponentsMessage.Type,
    ): UpdateReturn => folds.components(model, p.message),
    GotCollapsibleLibMessage: (p: typeof Message.GotCollapsibleLibMessage.Type): UpdateReturn =>
      folds.lib(model, p.message),
    GotCollapsibleSettingsMessage: (
      p: typeof Message.GotCollapsibleSettingsMessage.Type,
    ): UpdateReturn => folds.settings(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/collapsible

Source

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

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

type Child = Html | string

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

// A single collapsible section — mirrors shadcn's `collapsible`
// (apps/v4/registry/bases/base/ui/collapsible.tsx): a trigger that toggles
// one panel open/closed. The open state is owned by this Submodel: embed it
// with `h.submodel` and listen for `ChangedOpen` if your app needs to react.
// For a stack of sections with exclusive or multi-open semantics, see
// `accordion`.

export const buttonId = FoldkitDisclosure.buttonId

export const collapsibleWrapperClass = 'w-full'

export const collapsibleTriggerClass =
  'group/collapsible-trigger flex w-full items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-left text-sm font-medium text-foreground transition-[background-color,color,box-shadow] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-50 select-none data-[open]:rounded-b-none motion-reduce:transition-none'

export const collapsibleContentClass =
  'overflow-hidden rounded-b-lg border border-t-0 border-border bg-card px-4 py-3 text-sm text-muted-foreground [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4'

export const collapsibleAnimatedContentClass =
  'overflow-hidden rounded-b-lg border-x border-b border-t-0 border-border bg-card px-4 py-3 text-sm text-muted-foreground [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4'

export const collapsibleChevronClass = 'size-4 shrink-0 text-muted-foreground pointer-events-none'

// MODEL

export const Model = S.Struct({
  id: S.String,
  isOpen: S.Boolean,
  /** Animates the panel height via the @foldkit/ui Disclosure helper's
   *  `animatePanel` transition. */
  isAnimated: S.Boolean,
})
export type Model = typeof Model.Type

// MESSAGES

/** The user clicked the trigger. Flips the open state. */
export const Message = defineMessageUnion({
  Toggled: {},
})
export type Message = typeof Message.Type

/** Emitted when the open state changes. */
export const OutMessage = defineMessageUnion({
  ChangedOpen: { isOpen: S.Boolean },
})
export type OutMessage = typeof OutMessage.Type

// INIT / UPDATE

export type InitConfig = Readonly<{
  id: string
  isOpen?: boolean
  isAnimated?: boolean
}>

/** Creates an initial collapsible model. */
export const init = (config: InitConfig): Model => ({
  id: config.id,
  isOpen: config.isOpen ?? false,
  isAnimated: config.isAnimated ?? false,
})

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

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

/** Processes a collapsible 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 'Toggled': {
      const isOpen = !model.isOpen
      return {
        model: evo(model, { isOpen: () => isOpen }),
        outMessage: OutMessage.ChangedOpen({ isOpen }),
      }
    }
  }
}

// VIEW

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

/** Renders the styled collapsible section built on the @foldkit/ui Disclosure
 *  helper. Embedded via `h.submodel`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) =>
  FoldkitDisclosure.view(
    {
      id: model.id,
      isOpen: model.isOpen,
      onToggle: () => Message.Toggled(),
      isDisabled: viewInputs.isDisabled,
      ariaLabel: viewInputs.ariaLabel,
      ariaLabelledBy: viewInputs.ariaLabelledBy,
      toView: ({ button, panel, animatePanel }) =>
        h.div(
          [
            h.Class(cn(collapsibleWrapperClass, viewInputs.wrapperClass)),
            h.DataAttribute('slot', 'collapsible'),
          ],
          [
            h.button(
              [
                ...button,
                h.Class(cn(collapsibleTriggerClass, viewInputs.triggerClass)),
                h.DataAttribute('slot', 'collapsible-trigger'),
              ],
              [
                h.div(
                  [h.Class('flex w-full items-center justify-between gap-2')],
                  [
                    h.span([], [viewInputs.title]),
                    h.span(
                      [h.Class('flex shrink-0 items-center gap-1')],
                      [
                        /* ChevronDown shown when collapsed; hidden while the trigger is expanded. */
                        h.span(
                          [
                            h.Class(
                              cn(
                                collapsibleChevronClass,
                                'group-aria-expanded/collapsible-trigger:hidden',
                              ),
                            ),
                          ],
                          [icon(h, ChevronDown)],
                        ),
                        /* ChevronUp shown when expanded; hidden while the trigger is collapsed. */
                        h.span(
                          [
                            h.Class(
                              cn(
                                collapsibleChevronClass,
                                'hidden group-aria-expanded/collapsible-trigger:inline',
                              ),
                            ),
                          ],
                          [icon(h, ChevronUp)],
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            ),
            model.isAnimated
              ? animatePanel(
                  h.div(
                    [
                      ...panel,
                      h.Class(cn(collapsibleAnimatedContentClass, viewInputs.contentClass)),
                      h.DataAttribute('slot', 'collapsible-content'),
                    ],
                    [viewInputs.content],
                  ),
                )
              : model.isOpen
                ? h.div(
                    [
                      ...panel,
                      h.Class(cn(collapsibleContentClass, viewInputs.contentClass)),
                      h.DataAttribute('slot', 'collapsible-content'),
                    ],
                    [viewInputs.content],
                  )
                : h.empty,
          ],
        ),
    },
    h,
  ),
)