Collapsible
A single disclosure whose content springs open to its natural height and blurs into focus.
State: closed
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/collapsible.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/collapsible/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, useId } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
/** Trigger text. Use the `#trigger` slot for richer content. */
title?: string
/** Prevents toggling. */
disabled?: boolean
class?: HTMLAttributes['class']
/** Classes for the trigger button. */
triggerClass?: HTMLAttributes['class']
/** Classes for the content wrapper. */
contentClass?: HTMLAttributes['class']
}>(), {
disabled: false,
})
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ toggle: [open: boolean] }>()
const uid = useId()
const reduced = useReducedMotion()
// A soft, slightly underdamped spring so the height settles with a little life.
const spring = { type: 'spring', stiffness: 300, damping: 28, mass: 0.9 } as const
const transition = computed(() => (reduced.value ? { duration: 0 } : spring))
function toggle(value = !open.value) {
if (props.disabled || value === open.value)
return
open.value = value
emit('toggle', value)
}
defineExpose({
/** Toggle, or force a state with `toggle(true)` / `toggle(false)`. */
toggle,
show: () => toggle(true),
hide: () => toggle(false),
})
</script>
<template>
<div
:class="cn('w-full overflow-hidden rounded-xl border bg-card text-card-foreground', props.class)"
:data-state="open ? 'open' : 'closed'"
>
<button
:id="`${uid}-trigger`"
type="button"
:class="cn(
'flex w-full items-center justify-between gap-4 rounded-[inherit] px-4 py-3 text-left text-sm font-medium outline-none transition-colors hover:bg-muted/50 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
props.triggerClass,
)"
:aria-expanded="open"
:aria-controls="`${uid}-content`"
:disabled="props.disabled"
@click="toggle()"
>
<span class="min-w-0 flex-1">
<slot name="trigger" :open="open">{{ props.title }}</slot>
</span>
<motion.span
aria-hidden="true"
class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground"
:animate="{ rotate: open ? 90 : 0 }"
:transition="transition"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
</motion.span>
</button>
<AnimatePresence :initial="false">
<motion.div
v-if="open"
:id="`${uid}-content`"
key="content"
role="region"
:aria-labelledby="`${uid}-trigger`"
class="overflow-hidden"
:initial="{ height: 0 }"
:animate="{ height: 'auto' }"
:exit="{ height: 0 }"
:transition="transition"
>
<motion.div
:class="cn('px-4 pb-4 pt-1 text-sm text-muted-foreground', props.contentClass)"
:initial="{ opacity: 0, filter: 'blur(6px)', scale: 0.98 }"
:animate="{ opacity: 1, filter: 'blur(0px)', scale: 1 }"
:exit="{ opacity: 0, filter: 'blur(6px)', scale: 0.98 }"
:transition="reduced ? { duration: 0 } : { duration: 0.28, ease: [0.22, 1, 0.36, 1] }"
style="transform-origin: top center"
>
<slot :close="() => toggle(false)" />
</motion.div>
</motion.div>
</AnimatePresence>
</div>
</template>ts
export { default as Collapsible } from './Collapsible.vue'Usage
ts
import { Collapsible } from '@/components/ui/collapsible'vue
<Collapsible v-model:open="open" title="Show advanced settings">
<p>Anything you like.</p>
</Collapsible>API
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:open | boolean | false | Open state. |
title | string | — | Trigger text (or use the #trigger slot). |
disabled | boolean | false | Prevents toggling. |
triggerClass | string | — | Classes for the trigger button. |
contentClass | string | — | Classes for the content wrapper. |
| Slot | Props | Description |
|---|---|---|
trigger | { open } | Custom trigger content; the chevron is added for you. |
default | { close } | The collapsible content. |
| Event | Payload | Description |
|---|---|---|
toggle | open: boolean | Fired after the state changes from the trigger or an exposed method. |
Exposed via a template ref: toggle(force?), show() and hide().
The trigger is a real <button> with aria-expanded / aria-controls, and the content is a labelled region. With prefers-reduced-motion the panel opens instantly.