# Dialog Morph From Button — React Dialog Component

> A button that grows into the dialog itself through a shared layout, with its label travelling to become the title, then shrinks back on close.

- Category: [Dialogs & Drawers](https://pixelpapa.com/components/dialogs.md)
- Tags: dialog, morph, layout, shared-element, feedback
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/dialogs/dialog-morph-from-button

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dialog-morph-from-button.json"
```

The file is written to `components/pixelpapa/dialog-morph-from-button.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Angry, Check, Frown, Laugh, Meh, MessageSquarePlus, Smile } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from "react";
import { cn } from "@/lib/utils";

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

/** Moves focus into `ref` while open, keeps Tab inside it, closes on Escape and restores focus afterwards. */
function useModalFocus(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    const node = ref.current;
    if (!open || !node) return;
    const opener = document.activeElement as HTMLElement | null;
    (node.querySelector<HTMLElement>("[data-autofocus]") ?? node).focus({ preventScroll: true });
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") return close.current();
      if (e.key !== "Tab") return;
      const items = [...node.querySelectorAll<HTMLElement>(FOCUSABLE)];
      const first = items[0];
      const last = items[items.length - 1];
      const at = document.activeElement;
      if (!first || (e.shiftKey ? at === first || at === node : at === last)) {
        e.preventDefault();
        (e.shiftKey ? last : first)?.focus();
      }
    };
    node.addEventListener("keydown", onKeyDown);
    return () => {
      node.removeEventListener("keydown", onKeyDown);
      opener?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

type MorphDialogProps = {
  /** Shown on the button and, after the morph, as the dialog title. */
  label: string;
  icon?: ReactNode;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  children: ReactNode;
  /** Class for the overlay. Covers the nearest positioned ancestor; pass "fixed" for the viewport. */
  className?: string;
};

const morph = { type: "spring", stiffness: 380, damping: 34 } as const;

export function MorphDialog({ label, icon, open, onOpenChange, children, className }: MorphDialogProps) {
  const id = useId();
  const panel = useRef<HTMLDivElement>(null);
  useModalFocus(panel, open, () => onOpenChange(false));

  return (
    <>
      {/* The trigger stays mounted; Motion cross-fades it with the dialog that shares its layoutId. */}
      <motion.button
        layoutId={`dialog-morph-${id}`}
        transition={morph}
        onClick={() => onOpenChange(true)}
        aria-haspopup="dialog"
        aria-expanded={open}
        style={{ borderRadius: 999 }}
        className="relative inline-flex h-11 items-center gap-2 border border-border bg-card px-5 text-sm font-semibold text-card-foreground shadow-lg shadow-black/5"
      >
        {icon}
        <motion.span layoutId={`dialog-morph-label-${id}`} transition={morph} className="inline-block">
          {label}
        </motion.span>
      </motion.button>

      <AnimatePresence>
        {open && (
          <motion.div key="overlay" className={cn("absolute inset-0 z-50 grid place-items-center p-4", className)}>
            <motion.div
              aria-hidden
              onClick={() => onOpenChange(false)}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="absolute inset-0 bg-black/35 backdrop-blur-[3px]"
            />
            <motion.div
              ref={panel}
              layoutId={`dialog-morph-${id}`}
              transition={morph}
              role="dialog"
              aria-modal="true"
              aria-labelledby={`${id}-title`}
              tabIndex={-1}
              style={{ borderRadius: 24 }}
              className="relative w-full max-w-sm overflow-hidden border border-border bg-card p-5 text-card-foreground shadow-2xl shadow-black/20 outline-none"
            >
              <h2 id={`${id}-title`} className="text-sm font-semibold">
                <motion.span layoutId={`dialog-morph-label-${id}`} transition={morph} className="inline-block">
                  {label}
                </motion.span>
              </h2>
              <motion.div
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0, transition: { delay: 0.12, duration: 0.25 } }}
                exit={{ opacity: 0, transition: { duration: 0.08 } }}
              >
                {children}
              </motion.div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

const moods = [
  { icon: Angry, label: "Terrible" },
  { icon: Frown, label: "Bad" },
  { icon: Meh, label: "Okay" },
  { icon: Smile, label: "Good" },
  { icon: Laugh, label: "Great" },
];

export default function DialogMorphFromButtonDemo() {
  const [open, setOpen] = useState(true);
  const [mood, setMood] = useState(3);
  const [sent, setSent] = useState(false);

  useEffect(() => {
    if (!sent) return;
    const t = setTimeout(() => setOpen(false), 1300);
    return () => clearTimeout(t);
  }, [sent]);

  return (
    <div className="relative isolate flex h-[420px] w-full max-w-[680px] items-center justify-center overflow-hidden rounded-3xl border border-border bg-muted/40 [background-image:radial-gradient(var(--border)_1px,transparent_1px)] [background-size:18px_18px]">
      <MorphDialog
        label="Send feedback"
        icon={<MessageSquarePlus className="size-4 text-primary" />}
        open={open}
        onOpenChange={(next) => {
          setOpen(next);
          if (next) setSent(false);
        }}
      >
        <AnimatePresence mode="wait" initial={false}>
          {sent ? (
            <motion.div key="done" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="flex h-[212px] flex-col items-center justify-center text-center">
              <motion.span
                initial={{ scale: 0 }}
                animate={{ scale: 1 }}
                transition={{ type: "spring", stiffness: 420, damping: 16 }}
                className="flex size-12 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
              >
                <Check className="size-6" strokeWidth={3} />
              </motion.span>
              <p className="mt-3 font-medium">Thanks, that helps.</p>
              <p className="text-sm text-muted-foreground">The product team reads every note.</p>
            </motion.div>
          ) : (
            <motion.form
              key="form"
              exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.12 } }}
              onSubmit={(e) => {
                e.preventDefault();
                setSent(true);
              }}
              className="h-[212px]"
            >
              <p className="mt-1 text-sm text-muted-foreground">How is the new editor treating you?</p>
              <div role="radiogroup" aria-label="Mood" className="mt-3 flex gap-1.5">
                {moods.map((m, i) => (
                  <button
                    key={m.label}
                    type="button"
                    role="radio"
                    aria-checked={mood === i}
                    aria-label={m.label}
                    onClick={() => setMood(i)}
                    className={cn("relative flex h-10 flex-1 items-center justify-center rounded-xl transition-colors", mood === i ? "text-primary" : "text-muted-foreground hover:text-foreground")}
                  >
                    {mood === i && (
                      <motion.span layoutId="dialog-morph-from-button-mood" className="absolute inset-0 rounded-xl bg-primary/12 ring-1 ring-primary/30" transition={{ type: "spring", stiffness: 460, damping: 30 }} />
                    )}
                    <m.icon className="relative size-5" />
                  </button>
                ))}
              </div>
              <textarea
                aria-label="Your feedback"
                rows={3}
                defaultValue="Slash commands are great. I'd love one for tables."
                className="mt-3 block w-full resize-none rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
              />
              <div className="mt-3 flex justify-end gap-2">
                <button type="button" onClick={() => setOpen(false)} className="h-9 rounded-lg px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
                  Cancel
                </button>
                <button type="submit" className="h-9 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm shadow-primary/30 transition-transform active:scale-95">
                  Send
                </button>
              </div>
            </motion.form>
          )}
        </AnimatePresence>
      </MorphDialog>
    </div>
  );
}
```

## AI prompt

```text
Add the "Dialog Morph From Button" component from PixelPapa UI to this project.

What it is: A button that grows into the dialog itself through a shared layout, with its label travelling to become the title, then shrinks back on close.
Category: Dialogs & Drawers. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dialog-morph-from-button.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dialog-morph-from-button.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dialog-morph-from-button" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More dialogs & drawers

- [Dialog Command Style Search](https://pixelpapa.com/components/dialogs/dialog-command-style-search.md) (Pro): A search dialog in the style of a command palette: scope chips, matches highlighted as you type, and a selection bar that springs between results with the arrow keys.
- [Dialog Confirm Destructive](https://pixelpapa.com/components/dialogs/dialog-confirm-destructive.md) (Pro): A type-to-confirm dialog: the delete button fills up as each character of the resource name matches, unlocks at the last one and shakes on a wrong try.
- [Dialog Multi Step Wizard](https://pixelpapa.com/components/dialogs/dialog-multi-step-wizard.md) (Pro): A wizard dialog whose steps slide in the direction of travel while the card springs to each step's height and a segmented progress bar fills.
- [Dialog Scale Spring](https://pixelpapa.com/components/dialogs/dialog-scale-spring.md): A modal that springs up from 86% scale over a blurred scrim, traps focus, closes on Escape or outside click and hands focus back to its trigger.
- [Drawer Bottom Sheet Drag](https://pixelpapa.com/components/dialogs/drawer-bottom-sheet-drag.md): A bottom sheet you can drag down to dismiss: a slow pull past the threshold or a quick flick closes it, and the scrim fades with the drag distance.
- [Drawer Side Panel](https://pixelpapa.com/components/dialogs/drawer-side-panel.md) (Pro): A floating settings panel that slides in from the right with staggered rows while the page behind recedes and dims.
