Teseria
All elements

Morphing dialog

A card that is also a dialog. Press it and the same surface grows to the middle of the screen while the backdrop fades in; its extra content arrives a beat later.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

MorphDialog.tsx137 lines
"use client";
// MorphDialog: a card that is also a dialog. Press it and the same surface grows to the middle of the screen while
// the backdrop fades in; its extra content arrives a beat later. Close and it shrinks back into its slot.
// Picture paths default to Teseria's demo art (/art/*.svg); pass your own src.
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent as RKE, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";

export type MorphDialogCue = "unfold" | "fold";

export type MorphDialogProps = {
  /** Shown in the card and, larger, in the dialog. */
  title?: ReactNode;
  /** Line under the title, in both states. */
  summary?: ReactNode;
  /** Only shown once the card has grown into the dialog. */
  children?: ReactNode;
  /** Picture URL. The default only exists in Teseria. */
  src?: string;
  /** Picture alt text. */
  alt?: string;
  /** Card width in px. */
  width?: number;
  /** Dialog width in px. */
  dialogWidth?: number;
  /** Card corner radius in px; the dialog is 4px rounder. */
  corner?: number;
  /** Controlled open state. */
  open?: boolean;
  /** Initial open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Called when the dialog opens or closes. */
  onOpenChange?: (open: boolean) => void;
  /** Label of the close button. */
  closeLabel?: string;
  /** Sound hook: "unfold" on open, "fold" on close. */
  onSound?: (cue: MorphDialogCue) => void;
  className?: string;
  style?: CSSProperties;
};

const MORPH = { type: "spring", stiffness: 340, damping: 30, mass: 0.8 } as const;

export function MorphDialog({
  title = "Lisbon offsite",
  summary = "4 nights · 12 people",
  children = <p>Flights land Tuesday afternoon. Rooftop at the studio from Wednesday; Thursday is free. Bring a jacket for the evenings.</p>,
  src = "/art/tide.svg",
  alt = "Tide",
  width = 240,
  dialogWidth = 420,
  corner = 20,
  open: controlled,
  defaultOpen = false,
  onOpenChange,
  closeLabel = "Close",
  onSound,
  className,
  style,
}: MorphDialogProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultOpen);
  const open = controlled ?? inner;
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  const card = useRef<HTMLButtonElement>(null);
  const set = (v: boolean) => {
    onSound?.(v ? "unfold" : "fold");
    setInner(v);
    onOpenChange?.(v);
    if (!v) setTimeout(() => card.current?.focus({ preventScroll: true }), 0);
  };
  const closeRef = useRef(() => {});
  closeRef.current = () => set(false);
  useEffect(() => {
    if (!open) return;
    const k = (e: KeyboardEvent) => e.key === "Escape" && closeRef.current();
    document.addEventListener("keydown", k);
    return () => document.removeEventListener("keydown", k);
  }, [open]);

  // Tab wraps inside the open dialog
  const trap = (e: RKE<HTMLElement>) => {
    if (e.key !== "Tab") return;
    const stops = [...e.currentTarget.querySelectorAll<HTMLElement>("a[href], button:not(:disabled), input, textarea, select, [tabindex]:not([tabindex='-1'])")];
    const edge = stops[e.shiftKey ? 0 : stops.length - 1];
    if (document.activeElement !== edge) return;
    e.preventDefault();
    stops[e.shiftKey ? stops.length - 1 : 0].focus();
  };

  return (
    <>
      <motion.button ref={card} type="button" className={`mdl-card mdl-root${className ? ` ${className}` : ""}`} layoutId={`${id}-s`} style={{ ...style, width, borderRadius: corner, visibility: open ? "hidden" : "visible" }} onClick={() => set(true)} transition={MORPH} aria-haspopup="dialog" aria-expanded={open}>
        <motion.img layoutId={`${id}-art`} className="mdl-art" src={src} alt={alt} draggable={false} style={{ borderRadius: corner - 6 }} transition={MORPH} />
        <motion.span layoutId={`${id}-t`} className="mdl-title" transition={MORPH}>
          {title}
        </motion.span>
        {summary && (
          <motion.span layoutId={`${id}-sum`} className="mdl-sum" transition={MORPH}>
            {summary}
          </motion.span>
        )}
      </motion.button>
      {mounted &&
        createPortal(
          <AnimatePresence>
            {open && (
              <motion.div key="bg" className="mdl-bg mdl-root" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.18 } }} transition={{ duration: 0.26 }} onClick={() => set(false)}>
                <motion.div role="dialog" aria-modal aria-label={typeof title === "string" ? title : undefined} className="mdl-dialog" layoutId={`${id}-s`} style={{ width: dialogWidth, borderRadius: corner + 4 }} transition={MORPH} onClick={(e) => e.stopPropagation()} onKeyDown={trap}>
                  <motion.img layoutId={`${id}-art`} className="mdl-art mdl-art-big" src={src} alt={alt} draggable={false} style={{ borderRadius: corner - 2 }} transition={MORPH} />
                  <div className="mdl-body">
                    <motion.span layoutId={`${id}-t`} className="mdl-title mdl-title-big" transition={MORPH}>
                      {title}
                    </motion.span>
                    {summary && (
                      <motion.span layoutId={`${id}-sum`} className="mdl-sum" transition={MORPH}>
                        {summary}
                      </motion.span>
                    )}
                    <motion.div className="mdl-more" initial={{ opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.1 } }} transition={{ duration: 0.22, delay: 0.14 }}>
                      {children}
                    </motion.div>
                  </div>
                  <motion.button type="button" className="mdl-x" aria-label={closeLabel} onClick={() => set(false)} ref={(el) => { if (el) setTimeout(() => el.focus({ preventScroll: true }), 0); }} initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }} transition={{ type: "spring", stiffness: 500, damping: 18, mass: 0.7, delay: 0.1, opacity: { duration: 0.14, delay: 0.1 } }}>
                    <X size={15} strokeWidth={2.2} />
                  </motion.button>
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>,
          document.body,
        )}
    </>
  );
}
MorphDialog.css107 lines
/* Morphing dialog. Prefix mdl-. */
/* the card and the portalled backdrop both carry .mdl-root */
.mdl-root, .mdl-root *, .mdl-root *::before, .mdl-root *::after { box-sizing: border-box; }
.mdl-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.mdl-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
.mdl-root:focus-visible, .mdl-root :focus-visible { outline: 2px solid var(--ts-focus); outline-offset: 2px; }

.mdl-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
  padding: 8px 16px 16px;
  border: 0;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
.mdl-card:hover {
  box-shadow: var(--ts-edge), 0 10px 30px -18px rgba(0, 0, 0, 0.35);
}
.mdl-card:active {
  scale: 0.97;
}
.mdl-art {
  display: block;
  width: calc(100% + 16px);
  height: 110px;
  margin: 0 -8px 6px;
  object-fit: cover;
}
.mdl-art-big {
  height: 120px;
  margin: 0 0 0;
  width: 100%;
}
.mdl-title {
  display: block;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: -0.01em;
}
.mdl-title-big {
  font-size: 20px;
  letter-spacing: -0.02em;
}
.mdl-sum {
  display: block;
  font-size: 12.5px;
  /* a colour, not opacity: the shared-layout morph animates opacity to 1 and would flash it bright */
  color: var(--ts-fg-muted);
}
.mdl-bg {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  background: rgba(0, 0, 0, 0.32);
  backdrop-filter: blur(6px);
}
.mdl-dialog {
  position: relative;
  max-width: calc(100vw - 32px);
  padding: 8px;
  background: var(--ts-sunken);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge), 0 30px 60px -30px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}
.mdl-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px 10px 10px;
}
.mdl-more {
  margin-top: 8px;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--ts-fg-secondary);
}
.mdl-more p {
  margin: 0 0 8px;
}
.mdl-x {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  background: var(--ts-sunken);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1) !important;
}
.mdl-x:active {
  scale: 0.92;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { MorphDialog } from "./MorphDialog";
import "./MorphDialog.css";

export default function Demo() {
  return <MorphDialog />;
}