Expanding Button
A compact button that springs open into an inline panel of actions or a small form, then folds back.
vue
<script setup lang="ts">
import { ExpandingButton } from '@/components/ui/expanding-button'
import { Check, Link, Mail, MessageCircle, Plus, Send, Share2 } from 'lucide-vue-next'
import { ref } from 'vue'
const copied = ref(false)
const targets = [
{ label: 'Email', icon: Mail },
{ label: 'Messages', icon: MessageCircle },
{ label: 'Send to app', icon: Send },
]
function copy() {
copied.value = true
setTimeout(() => (copied.value = false), 1400)
}
const email = ref('')
const joined = ref('')
function join(close: () => void) {
if (!email.value.includes('@'))
return
joined.value = email.value
email.value = ''
close()
}
</script>
<template>
<div class="flex flex-col items-center gap-8">
<ExpandingButton label="Share">
<template #trigger>
<Share2 /> Share
</template>
<template #default>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-full bg-muted px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="copy"
>
<component :is="copied ? Check : Link" class="size-3.5" />
{{ copied ? 'Copied' : 'Copy link' }}
</button>
<button
v-for="t in targets"
:key="t.label"
type="button"
:aria-label="t.label"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<component :is="t.icon" class="size-4" />
</button>
</template>
</ExpandingButton>
<div class="flex flex-col items-center gap-2">
<ExpandingButton label="Join the waitlist" panel-label="Join the waitlist" variant="outline">
<template #trigger>
<Plus /> Join the waitlist
</template>
<template #default="{ close }">
<form class="flex items-center gap-1.5" @submit.prevent="join(close)">
<input
v-model="email"
type="email"
required
placeholder="you@company.com"
aria-label="Email address"
class="h-8 w-48 rounded-full border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<button
type="submit"
class="h-8 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Join
</button>
</form>
</template>
</ExpandingButton>
<p class="m-0 h-4 text-xs text-muted-foreground" aria-live="polite">
<template v-if="joined">
Thanks — we'll email {{ joined }}.
</template>
</p>
</div>
</div>
</template>The shell springs its real width and height (no scaling, so text never stretches) while the label and the panel cross-fade. Focus moves into the panel on open; Escape, the close button or a click outside folds it back and returns focus to the button.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/expanding-button.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/expanding-button/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useDomRef, useReducedMotion } from 'motion-v'
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
/** Trigger text (and accessible name when the trigger slot is icon-only). */
label: string
/** Accessible name of the expanded panel. Defaults to `label`. */
panelLabel?: string
/** `default`: a solid button that opens into a card. `outline`: card surface in both states. */
variant?: 'default' | 'outline'
/** Hide the built-in close button in the panel. */
hideClose?: boolean
class?: HTMLAttributes['class']
/** Classes for the panel's content row. */
panelClass?: HTMLAttributes['class']
}>(), {
variant: 'default',
hideClose: false,
})
const open = defineModel<boolean>('open', { default: false })
const sizeSpring = { type: 'spring', stiffness: 420, damping: 36, mass: 0.9 } as const
const uid = useId()
const reduced = useReducedMotion()
const root = useDomRef()
const triggerEl = ref<HTMLElement | null>(null)
const panelEl = ref<HTMLElement | null>(null)
const triggerButton = ref<HTMLButtonElement>()
// The shell animates its real width / height (no scale), so content never
// stretches — it is revealed as the shell grows.
const size = ref<{ width: number, height: number } | null>(null)
let observer: ResizeObserver | undefined
function measure() {
const el = open.value ? panelEl.value : triggerEl.value
if (el)
size.value = { width: el.offsetWidth, height: el.offsetHeight }
}
function track() {
observer?.disconnect()
const el = open.value ? panelEl.value : triggerEl.value
if (el && observer) {
observer.observe(el)
measure()
}
}
function close(returnFocus = true) {
if (!open.value)
return
const hadFocus = !!(root.value as HTMLElement | null)?.contains(document.activeElement)
open.value = false
if (returnFocus && hadFocus)
nextTick(() => triggerButton.value?.focus())
}
function onDocPointerDown(e: PointerEvent) {
if (root.value && !(root.value as HTMLElement).contains(e.target as Node))
close(false)
}
watch(open, async (isOpen) => {
await nextTick()
track()
if (isOpen) {
document.addEventListener('pointerdown', onDocPointerDown)
panelEl.value?.querySelector<HTMLElement>('input, button, [href], select, textarea, [tabindex]:not([tabindex="-1"])')?.focus()
}
else {
document.removeEventListener('pointerdown', onDocPointerDown)
}
}, { flush: 'post' })
onMounted(() => {
observer = new ResizeObserver(measure)
track()
if (open.value)
document.addEventListener('pointerdown', onDocPointerDown)
})
onBeforeUnmount(() => {
observer?.disconnect()
document.removeEventListener('pointerdown', onDocPointerDown)
})
const fade = {
initial: { opacity: 0, filter: 'blur(4px)' },
animate: { opacity: 1, filter: 'blur(0px)', transition: { duration: 0.22, delay: 0.06 } },
exit: { opacity: 0, filter: 'blur(4px)', transition: { duration: 0.12 } },
}
</script>
<template>
<motion.div
ref="root"
:class="cn(
'relative inline-block overflow-hidden rounded-[20px] align-top shadow-sm ring-1 transition-colors duration-300',
props.variant === 'default' && !open
? 'bg-primary text-primary-foreground ring-primary'
: 'bg-popover text-popover-foreground ring-border',
props.class,
)"
:initial="false"
:animate="size ?? undefined"
:transition="reduced ? { duration: 0 } : sizeSpring"
@keydown.escape="close()"
>
<AnimatePresence :initial="false" mode="popLayout" anchor-x="left">
<motion.div v-if="!open" key="trigger" v-bind="fade" class="w-max">
<div :ref="el => (triggerEl = el as HTMLElement | null)">
<button
ref="triggerButton"
type="button"
:aria-expanded="false"
:aria-controls="`${uid}-panel`"
class="inline-flex h-10 items-center gap-2 whitespace-nowrap rounded-[20px] px-4 text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 [&_svg]:size-4"
@click="open = true"
>
<slot name="trigger">
{{ props.label }}
</slot>
</button>
</div>
</motion.div>
<motion.div v-else key="panel" v-bind="fade" class="w-max">
<div
:id="`${uid}-panel`"
:ref="el => (panelEl = el as HTMLElement | null)"
role="group"
:aria-label="props.panelLabel ?? props.label"
:class="cn('flex items-center gap-1.5 p-1.5', props.panelClass)"
>
<slot :close="close" />
<button
v-if="!props.hideClose"
type="button"
aria-label="Close"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="close()"
>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</template>ts
export { default as ExpandingButton } from './ExpandingButton.vue'Usage
ts
import { ExpandingButton } from '@/components/ui/expanding-button'vue
<ExpandingButton v-model:open="open" label="Add item">
<template #trigger><Plus /> Add item</template>
<template #default="{ close }">
<form @submit.prevent="add(); close()">
<input v-model="name" aria-label="Item name">
<button type="submit">Add</button>
</form>
</template>
</ExpandingButton>API
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:open | boolean | false | Expanded state. |
label | string | — | Trigger text / accessible name. |
panelLabel | string | label | Accessible name of the panel. |
variant | 'default' | 'outline' | 'default' | Solid button that opens into a card, or card surface throughout. |
hideClose | boolean | false | Hide the built-in close button. |
panelClass | string | — | Classes for the panel row. |
| Slot | Props | Description |
|---|---|---|
trigger | — | Collapsed content (defaults to label). |
default | { close } | Panel content. |