Shape-shift dialog
A morphing dialog built with a shared-layout animation. When triggered, the dialog content morphs from the trigger element's position and size using layoutId, creating a smooth shape-shift transition instead of a plain fade-in.
Usage
Use MMorphingDialogTrigger as the element that triggers the dialog, and provide the dialog content via the content slot. By default, layoutId is enabled, causing the dialog to morph from the trigger. You can control the dialog state with v-model:open.
<template>
<div class="flex w-full max-w-md justify-center">
<div class="flex flex-col items-center gap-6">
<MMorphingDialog layout-id>
<MMorphingDialogTrigger class="rounded-lg bg-primary px-4 py-2 text-inverted">
Open Dialog
</MMorphingDialogTrigger>
<template #content="{ close }">
<div class="flex flex-col gap-4 p-6">
<h3 class="text-lg font-semibold">
Shape-shift dialog
</h3>
<p class="text-sm text-muted">
This dialog morphs from its trigger with a smooth layout animation.
</p>
<div class="flex justify-end">
<UButton
label="Close"
@click="close"
/>
</div>
</div>
</template>
</MMorphingDialog>
</div>
</div>
</template>
Examples
Product card
Grow a card into a detail view while the artwork and price stay anchored to the morphing element. This is the shape used most often for a catalogue or storefront.
<script setup lang="ts">
const product = {
name: 'Bauhaus Table Lamp',
price: '$349',
description:
'A sculptural brass table lamp with an adjustable shade, produced in small batches and inspired by 1920s Bauhaus design.',
image:
'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=800&q=80',
}
</script>
<template>
<MMorphingDialog layout-id>
<MMorphingDialogTrigger
class="w-64 overflow-hidden rounded-2xl border border-default bg-elevated text-left"
>
<img
:src="product.image"
:alt="product.name"
class="h-40 w-full object-cover"
>
<div class="flex items-center justify-between gap-2 p-4">
<span class="text-sm font-medium text-highlighted">{{ product.name }}</span>
<span class="text-sm text-muted">{{ product.price }}</span>
</div>
</MMorphingDialogTrigger>
<template #content="{ close }">
<div class="w-full">
<img
:src="product.image"
:alt="product.name"
class="h-56 w-full object-cover"
>
<div class="flex flex-col gap-4 p-6">
<div class="flex items-start justify-between gap-4">
<h3 class="text-lg font-semibold text-highlighted">
{{ product.name }}
</h3>
<span class="text-lg font-medium text-muted">{{ product.price }}</span>
</div>
<p class="text-sm text-muted">
{{ product.description }}
</p>
<div class="flex justify-end gap-2">
<UButton
label="Close"
color="neutral"
variant="outline"
@click="close"
/>
<UButton
label="Add to cart"
@click="close"
/>
</div>
</div>
</div>
</template>
</MMorphingDialog>
</template>
Reading panel
A compact row can expand into a scrollable panel. The dialog content is a plain
container, so an overflow-y-auto child scrolls on its own without moving the
backdrop.
<script setup lang="ts">
const books = [
{
title: 'Norwegian Wood',
author: 'Haruki Murakami',
excerpt:
'I was thirty-seven then, strapped in my seat as the huge 747 plunged through dense cloud cover on approach to Hamburg airport. Cold November rains drenched the earth and lent everything the gloomy air of a Flemish landscape: the ground crew in rain gear, a flag atop a squat airport building, a BMW billboard. So Germany again. Once the plane was on the ground, soft music began to flow from the ceiling speakers.',
},
{
title: 'The Wind-Up Bird Chronicle',
author: 'Haruki Murakami',
excerpt:
'When the phone rang I was in the kitchen, boiling a potful of spaghetti and whistling along with an FM broadcast of the overture to Rossini\'s The Thieving Magpie, which has to be the perfect music for cooking pasta. I wanted to ignore the phone, not only because the spaghetti was nearly done, but because Claudio Abbado was bringing the London Symphony to its musical climax.',
},
{
title: 'Kafka on the Shore',
author: 'Haruki Murakami',
excerpt:
'Cash isn\'t the only thing you can have too much of. Money finds its way into your pocket and out again, but solitude is a different matter. It settles in. It has weight. The kind of weight that makes the air in a room feel thicker, until you are sure you can see the shape of it when you breathe out.',
},
]
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-2">
<MMorphingDialog
v-for="book in books"
:key="book.title"
layout-id
>
<MMorphingDialogTrigger class="flex w-full items-center gap-3 rounded-xl border border-default bg-elevated p-3 text-left">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-sm font-semibold text-primary">
{{ book.title.charAt(0) }}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-medium">{{ book.title }}</span>
<span class="block truncate text-xs text-muted">{{ book.author }}</span>
</span>
</MMorphingDialogTrigger>
<template #content="{ close }">
<div class="flex max-h-[70vh] flex-col">
<div class="border-b border-default p-6">
<h3 class="text-lg font-semibold text-highlighted">
{{ book.title }}
</h3>
<p class="text-sm text-muted">
{{ book.author }}
</p>
</div>
<div class="overflow-y-auto p-6 text-sm leading-relaxed text-muted">
<p>{{ book.excerpt }}</p>
</div>
<div class="flex justify-end border-t border-default p-4">
<UButton
label="Close"
color="neutral"
variant="outline"
@click="close"
/>
</div>
</div>
</template>
</MMorphingDialog>
</div>
</template>
Image lightbox
For a gallery, each thumbnail morphs into a larger view of the same image. Because
every dialog gets its own shared layoutId, a grid of them stays independent.
<script setup lang="ts">
const photos = [
{
src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=800&q=80',
alt: 'Mountain ridge at dawn',
},
{
src: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=800&q=80',
alt: 'Snow-covered peaks',
},
{
src: 'https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&w=800&q=80',
alt: 'Forest canopy',
},
]
</script>
<template>
<div class="grid grid-cols-3 gap-3">
<MMorphingDialog
v-for="photo in photos"
:key="photo.src"
layout-id
>
<MMorphingDialogTrigger class="overflow-hidden rounded-xl border border-default">
<img
:src="photo.src"
:alt="photo.alt"
class="h-24 w-full object-cover"
>
</MMorphingDialogTrigger>
<template #content="{ close }">
<figure class="w-full">
<img
:src="photo.src"
:alt="photo.alt"
class="max-h-[65vh] w-full object-cover"
>
<figcaption class="flex items-center justify-between gap-4 p-4">
<span class="text-sm text-muted">{{ photo.alt }}</span>
<UButton
label="Close"
color="neutral"
variant="outline"
@click="close"
/>
</figcaption>
</figure>
</template>
</MMorphingDialog>
</div>
</template>
Customize
<template>
<div class="flex w-full justify-center">
<MMorphingDialog layout-id>
<MMorphingDialogTrigger class="w-64 overflow-hidden rounded-2xl border bg-elevated text-left">
<div class="h-28 bg-gradient-to-br from-primary to-secondary" />
<div class="flex items-center gap-3 p-4">
<span class="grid size-10 shrink-0 place-items-center rounded-full bg-primary text-inverted">
AM
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">
Ada Lovelace
</p>
<p class="truncate text-xs text-muted">
Mathematician
</p>
</div>
</div>
</MMorphingDialogTrigger>
<template #content="{ close }">
<div class="w-full">
<div class="h-40 bg-gradient-to-br from-primary to-secondary" />
<div class="flex flex-col gap-4 p-6">
<div class="flex items-center gap-3">
<div class="flex size-12 items-center justify-center rounded-full bg-primary text-lg font-semibold text-inverted">
AL
</div>
<div>
<p class="text-base font-semibold">
Ada Lovelace
</p>
<p class="text-sm text-muted">
Mathematician · London
</p>
</div>
</div>
<p class="text-sm text-muted">
The dialog shares the trigger's layout id, so it grows out of the card
instead of fading in.
</p>
<div class="flex justify-end gap-2">
<UButton
label="Close"
color="neutral"
variant="outline"
@click="close"
/>
</div>
</div>
</div>
</template>
</MMorphingDialog>
</div>
</template>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | undefined | Controlled open state. Use with v-model:open. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
layoutId | boolean | true | Whether to enable layout morphing from trigger. When true, the dialog content uses layoutId based on the component's unique ID. |
title | string | undefined | Optional title rendered in a header above the content. |
description | string | undefined | Optional description rendered under the title in the header. |
overlay | boolean | true | Whether to show the dimmed, blurred backdrop behind the dialog. |
scrollable | boolean | false | Allows the dialog wrapper to scroll when the content is taller than the viewport. |
fullscreen | boolean | false | Stretches the dialog content to fill the entire viewport. |
portal | string | boolean | true | Teleport target. true renders into body, a string targets a selector, and false renders the dialog inline. |
dismissible | boolean | true | Whether the dialog can be dismissed by clicking the backdrop or pressing Escape. |
closeIcon | string | undefined | When set, renders a close button in the corner of the dialog using this icon name. |
modal | boolean | true | When true, page scroll is locked while the dialog is open and focus moves into the dialog. |
class | string | undefined | Additional classes applied to the dialog content. |
ui | Record<string, any> | undefined | Slot-based class overrides for the wrapper and content elements. |
scale | number | 0.95 | Scale value for the content when layoutId is disabled. |
variants | Variants | () => ({}) | Custom motion variants merged over the content's enter/exit animations. |
transition | Transition | () => ({ type: 'spring', bounce: 0.15, duration: 0.4 }) | Transition used for the content layout and enter/exit animations. |
Events
| Event | Payload | Description |
|---|---|---|
update:open | (open: boolean) => void | Emitted when open state changes. |
enter | () => void | Emitted when dialog enters. |
after:enter | () => void | Emitted after dialog enters. |
leave | () => void | Emitted when dialog leaves. |
after:leave | () => void | Emitted after dialog leaves. |
Slots
| Slot | Props | Description |
|---|---|---|
default | (props: { isOpen: boolean }) => void | The trigger content. Wrap MMorphingDialogTrigger here. |
content | (props: { close: () => void, isOpen: boolean }) => void | The dialog body. Use the exposed close function to close the dialog. |
UI
Two structural elements can be restyled per instance through the ui prop, merged
over the built-in classes:
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-element class overrides. |
| Slot | Props | Description |
|---|---|---|
wrapper | - | The fixed, full-viewport overlay container that holds the backdrop and the content. |
content | - | The morphing content element — the one carrying the shared layoutId. |
Accessibility
Renders a role="dialog" element with aria-modal="true", moves focus into the dialog on open, and restores focus to the trigger on close. Escape closes the dialog, and page scroll is locked while it is visible. The trigger exposes aria-haspopup="dialog" and aria-expanded to reflect the state.
Performance
- Uses
layoutIdto share layout between the trigger and content so the morph is projected on the GPU withtransform, avoiding DOM reflows. AnimatePresencedrives enter/exit; layout, opacity, and scale run throughtransformonly.- When
layoutIdis enabled, scale is omitted in favor of the layout projection; when disabled, content scales fromscale(default0.95). - Unique IDs come from
useId()so multiple dialogs on a page keep independent layout IDs.