icon

Morainev0.5.0

Collapsible
elementscollapsible

Collapsible

Expandable content section with optional height transitions.

Import#

import { Collapsible } from 'moraine'

Slot Structure#

Single expandable section with a trigger slot.

root
├── trigger
└── content-wrapper
└── content

Examples#

Uncontrolled and Transition#

FAQ pattern with optional height transition. Spacing belongs inside the content so the wrapper can collapse without layout shifts.

function Uncontrolled() {
const [transition, setTransition] = createSignal(true)
return (
<div class="max-w-xs w-full space-y-3">
<Switch label="Transition" checked={transition()} onChange={setTransition} />
<div class="h-40">
<Card
classes={{
root: 'py-3 rounded-lg',
body: 'px-3 mb-0',
}}
>
<Collapsible
transition={transition()}
triggerRender={(context) => (
<Button
{...context.triggerProps}
variant="ghost"
class="text-sm w-full justify-between"
>
<span>How do I reset my password?</span>
<Icon
name="i-lucide-chevron-down"
aria-hidden="true"
class={`text-muted-foreground shrink-0 size-4 transition-transform ${context.isOpen ? 'rotate-180' : ''}`}
/>
</Button>
)}
>
<div class="text-sm text-muted-foreground pt-3">
You can reset your password from Account settings. We send a verification link to the
primary email on the workspace.
</div>
</Collapsible>
</Card>
</div>
</div>
)
}

Controlled and Disabled#

External state controls a billing panel and can temporarily disable the trigger while an operation is locked.

function Controlled() {
const [open, setOpen] = createSignal(true)
const [locked, setLocked] = createSignal(false)
return (
<div class="max-w-xl space-y-3">
<div class="flex flex-wrap gap-2 items-center">
<Button size="sm" onClick={() => !locked() && setOpen((value) => !value)}>
{open() ? 'Hide invoice details' : 'Show invoice details'}
</Button>
<Switch
label="Lock edits"
checked={locked()}
onChange={setLocked}
checkedIcon="i-lucide-lock"
uncheckedIcon="i-lucide-lock-open"
/>
</div>
<Collapsible
open={open()}
disabled={locked()}
onOpenChange={setOpen}
classes={{
root: 'w-108 rounded-lg b-(1 border)',
trigger:
'w-full px-4 py-3 text-left text-sm font-medium flex items-center justify-between data-disabled:opacity-60',
content: 'px-4 pb-4 text-sm text-foreground',
}}
triggerRender={(context) => (
<button {...context.triggerProps} class="flex w-full items-center justify-between">
<span>June invoice #INV-2048</span>
<Icon
name="i-lucide-chevron-down"
aria-label="Toggle invoice details"
class={cn('text-muted-foreground transition-transform', open() ? 'rotate-180' : '')}
/>
</button>
)}
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">Plan</span>
<span>Team Pro</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">Seats</span>
<span>18 active</span>
</div>
<div class="text-muted-foreground flex gap-2 items-center">
<Icon name="i-lucide-info" class="size-4" />
<span>Locked invoices cannot be expanded from the row trigger.</span>
</div>
</div>
</Collapsible>
</div>
)
}

Stateful Trigger#

Compose a compact row where only the icon button toggles detail content.

function Stateful() {
return (
<div class="max-w-xl space-y-2">
<Collapsible
defaultOpen
classes={{
root: 'rounded-lg b-(1 border)',
trigger: 'w-xl px-3 py-2 text-left text-sm flex items-center justify-between',
content: 'px-3 pb-3 text-sm text-foreground',
}}
triggerRender={(context) => (
<>
<div class="min-w-0">
<div class="flex gap-2 items-center">
<Icon name="i-lucide-rocket" class="text-muted-foreground shrink-0 size-4" />
<span class="font-medium truncate">Production deploy #4812</span>
</div>
<p class="text-xs text-muted-foreground mt-0.5">Completed 6 minutes ago</p>
</div>
<Button
{...context.triggerProps}
aria-label="Toggle deploy details"
size="sm"
variant="secondary"
classes={{
label: `transition-transform ${context.isOpen ? 'rotate-180' : ''}`,
}}
>
<Icon name="i-lucide-chevron-down" />
</Button>
</>
)}
>
<div class="space-y-1.5">
<p>Commit b8a29c7 promoted the billing workspace and search index workers.</p>
<p class="text-muted-foreground">Runtime: 2m 14s. Region: iad1. Rollback available.</p>
</div>
</Collapsible>
</div>
)
}

API Reference#

Attributes#

Slotroot0 attributes
Container that owns the trigger and expandable content state.
No attribute metadata for this slot.

Props#

PropTypeDefaultDescription
childrenJSX.Element
Content to render inside the collapsible.
classClassValue
Class applied to the component root or trigger element.
classesCollapsibleT.Classes | undefined
defaultOpenboolean | undefinedfalse
Whether the collapsible is open by default (uncontrolled).
disabledboolean | undefinedfalse
Whether the collapsible is disabled.
idstring | undefined
Unique identifier for the collapsible root element.
onOpenChange((open: boolean) => void) | undefined
Callback when the open state changes.
openboolean | undefined
Whether the collapsible is open (controlled).
refJSX.HTMLElementTags["div"] extends { ref?: infer Ref; } ? Ref : never | undefined
styleJSX.CSSProperties | undefined
stylesCollapsibleT.Styles | undefined
transitionboolean | undefinedfalse
Whether to keep content mounted until its height transition completes.
triggerRender*ComponentOrElement<CollapsibleT.TriggerRenderProps>
Custom trigger renderer. Spread `triggerProps` onto the interactive trigger element.