Morph Dialog

A card grows into a modal dialog and folds back when it closes.

"use client"

import {
  addTransitionType,
  createContext,
  startTransition,
  useContext,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  ViewTransition,
  type ComponentProps,
  type ReactNode,
  type RefObject,
} from "react"

const icon = {
  width: 16,
  height: 16,
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const
const X = ({ className }: { className?: string }) => (
  <svg {...icon} className={className}>
    <path d="M4.5 4.5l7 7" />
    <path d="M11.5 4.5l-7 7" />
  </svg>
)

const surface =
  "rounded-xl bg-card text-card-foreground ring-1 ring-foreground/10 [&[style*=view-transition-name]]:ring-0"
const share = { "dialog-close": "vt-move vt-expand vt-quick", default: "vt-move vt-expand" }

function animate(update: () => void) {
  document.activeViewTransition?.skipTransition()
  startTransition(update)
}

type DialogState = {
  id: string
  open: boolean
  show: () => void
  close: () => void
  closeNow: () => void
  trigger: RefObject<HTMLButtonElement | null>
}

const Context = createContext<DialogState | null>(null)

function useDialog() {
  const dialog = useContext(Context)
  if (!dialog) throw new Error("MorphDialog parts must be used inside <MorphDialog>")
  return dialog
}

export function MorphDialog({ children }: { children: ReactNode }) {
  const id = useId()
  const [open, setOpen] = useState(false)
  const trigger = useRef<HTMLButtonElement>(null)
  const state: DialogState = {
    id,
    open,
    trigger,
    show: () => animate(() => setOpen(true)),
    close: () =>
      animate(() => {
        addTransitionType("dialog-close")
        setOpen(false)
      }),
    closeNow: () => {
      document.activeViewTransition?.skipTransition()
      setOpen(false)
    },
  }
  return (
    <Context value={state}>
      <div className="text-foreground">{children}</div>
    </Context>
  )
}

export function MorphDialogTrigger({
  className = "",
  children,
}: {
  className?: string
  children: ReactNode
}) {
  const { id, open, show, trigger } = useDialog()
  if (open) {
    return (
      <div aria-hidden className={`invisible ${surface} ${className}`}>
        {children}
      </div>
    )
  }
  return (
    <ViewTransition key="trigger" name={`${id}-shell`} share={share}>
      <button
        ref={trigger}
        type="button"
        aria-haspopup="dialog"
        onClick={show}
        className={`block cursor-pointer text-left outline-none hover:ring-foreground/20 focus-visible:ring-3 focus-visible:ring-ring/50 ${surface} ${className}`}
      >
        {children}
      </button>
    </ViewTransition>
  )
}

export function MorphDialogContent({
  className = "",
  children,
}: {
  className?: string
  children: ReactNode
}) {
  const { id, open, close, closeNow, trigger } = useDialog()
  const dialog = useRef<HTMLDialogElement>(null)
  const panel = useRef<HTMLDivElement>(null)

  useLayoutEffect(() => {
    const el = dialog.current
    if (!el) return
    if (open && !el.open) el.showModal()
    if (!open && el.open) {
      el.close()
      trigger.current?.focus({ preventScroll: true })
    }
  }, [open, trigger])

  return (
    <dialog
      ref={dialog}
      aria-labelledby={`${id}-title`}
      aria-describedby={`${id}-description`}
      onCancel={(e) => {
        e.preventDefault()
        closeNow()
      }}
      onPointerDown={(e) => {
        const r = panel.current?.getBoundingClientRect()
        if (!r) return
        const inside =
          e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
        if (!inside) close()
      }}
      className="fixed inset-0 m-0 size-full max-h-none max-w-none overflow-y-auto overscroll-contain bg-background/80 p-0 text-foreground opacity-0 backdrop-blur-sm transition-[opacity,display,overlay] transition-discrete duration-200 ease-out backdrop:bg-transparent open:opacity-100 motion-reduce:duration-100 starting:open:opacity-0"
    >
      <div className="grid min-h-full place-items-center p-4">
        {open && (
          <ViewTransition key="panel" name={`${id}-shell`} share={share}>
            <div
              ref={panel}
              className={`relative w-full max-w-md p-5 text-sm ${surface} ${className}`}
            >
              {children}
              <button
                type="button"
                aria-label="Close"
                onClick={close}
                className="absolute top-3.5 right-3.5 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
              >
                <X className="size-4" />
              </button>
            </div>
          </ViewTransition>
        )}
      </div>
    </dialog>
  )
}

export function MorphDialogTitle({
  className = "",
  children,
}: {
  className?: string
  children: ReactNode
}) {
  const { id } = useDialog()
  return (
    <h2 id={`${id}-title`} className={`pr-8 text-base leading-6 font-semibold ${className}`}>
      {children}
    </h2>
  )
}

export function MorphDialogDescription({
  className = "",
  children,
}: {
  className?: string
  children: ReactNode
}) {
  const { id } = useDialog()
  return (
    <p id={`${id}-description`} className={`mt-1 text-muted-foreground ${className}`}>
      {children}
    </p>
  )
}

export function MorphDialogClose({ onClick, type = "button", ...props }: ComponentProps<"button">) {
  const { close } = useDialog()
  return (
    <button
      type={type}
      onClick={(e) => {
        onClick?.(e)
        close()
      }}
      {...props}
    />
  )
}

Installation

  1. 1Copy the Component tab into components/morph-dialog.tsx.
  2. 2Paste the CSS tab into globals.css, after Tailwind.

How it works

The trigger and the panel share one name, so React pairs them into one surface that grows from the card into the dialog and shrinks back. The panel sits in a native <dialog>, opened with showModal(), which gives you the top layer, focus trapping and an inert page for free. The dialog itself is the backdrop: it's never captured, so plain CSS fades it in and out while the panel morphs above it. Closing snaps faster with vt-quick; Escape closes it instantly. Outside clicks are checked against the panel's box, so a click on the panel mid-morph never closes it.

Uses
name + share, <dialog>, vt-expand, vt-quick
Theme
--background, --foreground, --card, --card-foreground, --muted, --muted-foreground, --ring, --radius