Button That Morphs into a Popover Note Form

A pill button in the corner of a team notes board that turns into its own popover. Pressing it stretches the same element into a small editor with a text area, a back button and a save button, and the button's label slides up to become the placeholder. Saving adds the note to the top of the list and the form folds back into the pill. On phones the board fills the width and the form opens to 288 pixels so it never leaves the screen.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/morphing-popover)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";

const startingNotes = [
  { id: 1, author: "IR", tone: "bg-lime-300 text-zinc-950", text: "Checkout copy approved. Ship the shorter version.", time: "09:12" },
  { id: 2, author: "TM", tone: "bg-sky-300 text-zinc-950", text: "Courier label printer is back online in bay 2.", time: "10:40" },
  { id: 3, author: "AS", tone: "bg-rose-300 text-zinc-950", text: "Friday stock count moved to 4 pm.", time: "11:05" },
];

const DEMO_TEXT = "Restock oat flour before the weekend";
const SPRING = { type: "spring" as const, bounce: 0.1, duration: 0.42 };

export default function ButtonThatMorphsIntoAPopoverNoteForm() {
  const reduceMotion = useReducedMotion();
  const [open, setOpen] = useState(false);
  const [draft, setDraft] = useState("");
  const [notes, setNotes] = useState(startingNotes);
  const touched = useRef(false);
  const byUser = useRef(false);
  const anchorRef = useRef<HTMLDivElement>(null);
  const fieldRef = useRef<HTMLTextAreaElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  // Demo: open the popover and type a note once, unless the visitor got there first.
  useEffect(() => {
    if (reduceMotion) {
      setOpen(true);
      setDraft(DEMO_TEXT);
      return;
    }
    const timers: ReturnType<typeof setTimeout>[] = [];
    timers.push(
      setTimeout(() => {
        if (touched.current) return;
        setOpen(true);
        DEMO_TEXT.split("").forEach((_, index) => {
          timers.push(
            setTimeout(() => {
              if (!touched.current) setDraft(DEMO_TEXT.slice(0, index + 1));
            }, 450 + index * 38)
          );
        });
      }, 700)
    );
    return () => timers.forEach(clearTimeout);
  }, [reduceMotion]);

  // Close on Escape or a press outside, the way a popover should.
  useEffect(() => {
    if (!open) return;
    if (byUser.current) fieldRef.current?.focus();
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") close();
    };
    const onDown = (event: PointerEvent) => {
      if (anchorRef.current && !anchorRef.current.contains(event.target as Node)) close();
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [open]);

  const touch = () => {
    if (!touched.current) {
      touched.current = true;
    }
    byUser.current = true;
  };
  const close = () => {
    touch();
    setOpen(false);
    requestAnimationFrame(() => triggerRef.current?.focus());
  };
  const save = () => {
    touch();
    const text = draft.trim();
    if (text) setNotes((list) => [{ id: Date.now(), author: "You", tone: "bg-white text-zinc-950", text, time: "Now" }, ...list].slice(0, 4));
    setDraft("");
    setOpen(false);
  };

  return (
    <MotionConfig transition={SPRING} reducedMotion="user">
      <section className="bg-zinc-950 px-5 py-14 text-zinc-100 sm:px-8 sm:py-16">
        <div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-2">
          <div>
            <p className="text-xs font-semibold uppercase tracking-widest text-lime-300">Quick capture</p>
            <h2 className="mt-4 text-4xl font-bold leading-tight tracking-tight sm:text-5xl">The button is the form.</h2>
            <p className="mt-5 max-w-md text-base leading-7 text-zinc-300">
              Press Add note and the pill stretches into a small editor right where it sat. Save, and it folds back into a button with your note at the top of the list.
            </p>
            <dl className="mt-8 grid max-w-md grid-cols-3 gap-4 border-t border-white/10 pt-6 text-sm">
              <div>
                <dt className="text-zinc-400">Opens with</dt>
                <dd className="mt-1 font-semibold text-white">Click or Enter</dd>
              </div>
              <div>
                <dt className="text-zinc-400">Closes with</dt>
                <dd className="mt-1 font-semibold text-white">Esc or outside</dd>
              </div>
              <div>
                <dt className="text-zinc-400">Layers</dt>
                <dd className="mt-1 font-semibold text-white">One element</dd>
              </div>
            </dl>
          </div>

          {/* Team board mock. The popover is anchored to its bottom right corner. */}
          <div className="relative mx-auto w-full max-w-md rounded-3xl border border-white/10 bg-zinc-900 p-5 shadow-2xl shadow-black/50 sm:p-6">
            <div className="flex items-center justify-between">
              <h3 className="text-sm font-semibold text-white">Bakehouse floor notes</h3>
              <span className="rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-zinc-200">{notes.length} today</span>
            </div>
            <ul className="mt-4 space-y-2.5 pb-36">
              <AnimatePresence initial={false}>
                {notes.map((note) => (
                  <motion.li
                    key={note.id}
                    layout
                    initial={{ opacity: 0, y: -12 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0 }}
                    className="flex items-start gap-3 rounded-2xl bg-white/5 p-3"
                  >
                    <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${note.tone}`}>{note.author.slice(0, 2)}</span>
                    <p className="flex-1 text-sm leading-5 text-zinc-100">{note.text}</p>
                    <span className="font-mono text-xs text-zinc-400">{note.time}</span>
                  </motion.li>
                ))}
              </AnimatePresence>
            </ul>

            <div ref={anchorRef} className="absolute bottom-5 right-5 flex h-11 items-end justify-end sm:bottom-6 sm:right-6">
              <AnimatePresence initial={false}>
                {open ? (
                  <motion.form
                    key="panel"
                    layoutId="morph-popover-shell"
                    style={{ borderRadius: 18 }}
                    className="absolute bottom-0 right-0 w-72 overflow-hidden bg-white text-zinc-900 shadow-2xl shadow-black ring-1 ring-black/10 sm:w-80"
                    onSubmit={(event) => {
                      event.preventDefault();
                      save();
                    }}
                  >
                    {!draft && (
                      <motion.span layoutId="morph-popover-label" aria-hidden="true" className="pointer-events-none absolute left-4 top-3.5 text-sm text-zinc-500">
                        Add note
                      </motion.span>
                    )}
                    <label htmlFor="morph-popover-note" className="sr-only">
                      Note
                    </label>
                    <textarea
                      id="morph-popover-note"
                      ref={fieldRef}
                      value={draft}
                      onChange={(event) => {
                        touch();
                        setDraft(event.target.value);
                      }}
                      onFocus={() => {
                        touched.current = true;
                      }}
                      rows={4}
                      className="block h-28 w-full resize-none bg-transparent px-4 py-3.5 text-sm leading-5 text-zinc-900 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-lime-500"
                    />
                    <motion.div
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1, transition: { delay: 0.12 } }}
                      exit={{ opacity: 0, transition: { duration: 0.05 } }}
                      className="flex items-center justify-between border-t border-zinc-200 px-2 py-2"
                    >
                      <button
                        type="button"
                        onClick={close}
                        aria-label="Close without saving"
                        className="flex h-10 w-10 items-center justify-center rounded-xl text-zinc-700 hover:bg-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-500"
                      >
                        <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                          <path d="M16 10H4m0 0 4.5-4.5M4 10l4.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                      </button>
                      <button
                        type="submit"
                        className="inline-flex h-10 items-center rounded-xl bg-zinc-950 px-4 text-sm font-semibold text-white hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-500 focus-visible:ring-offset-2"
                      >
                        Save note
                      </button>
                    </motion.div>
                  </motion.form>
                ) : (
                  <motion.button
                    key="trigger"
                    ref={triggerRef}
                    type="button"
                    layoutId="morph-popover-shell"
                    style={{ borderRadius: 18 }}
                    aria-haspopup="dialog"
                    aria-expanded={false}
                    onClick={() => {
                      touch();
                      setOpen(true);
                    }}
                    className="flex h-11 items-center gap-2 bg-lime-300 px-4 text-sm font-semibold text-zinc-950 shadow-lg shadow-lime-300/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
                  >
                    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                      <path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
                    </svg>
                    <motion.span layoutId="morph-popover-label">Add note</motion.span>
                  </motion.button>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>
      </section>
    </MotionConfig>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/button-that-morphs-into-a-popover-note-form.json

When to use it

How to customize it

Accessibility

Questions

How does the button turn into the popover?

The button and the form share one `layoutId`. Only one is rendered at a time, and when they swap, framer-motion animates position, size and corner radius from the old box to the new one, so it reads as a single element changing shape.

Why does the label move instead of fading?

The words 'Add note' carry their own `layoutId`, so they travel from the middle of the button to the placeholder position in the form. It hides as soon as there is text in the field.

Do I need a popover library for positioning?

Not for a fixed corner like this one. The form is absolutely positioned against a small anchor box. If your trigger can sit near any edge of the screen, add collision handling from a positioning library.

How do I send the note to a server?

Replace the body of `save` with your request and keep the two state updates. Close the form after the request succeeds if you want to show an error inside it.

Is the list animation required?

No. The `layout` prop on each list item only makes existing notes slide down when a new one arrives. Remove it and the list still works.

Adapted from Morphing Popover in Motion Primitives, used under its open-source licence. Licences and credits

Related: Card That Morphs into a Detail Dialog, Dark Toast Notification Stack with Undo and Progress, Delete Confirmation Dialog with Summary.

More modals and dialogs or all free UI components. Start for free to have a full site built around it.

More in Modals

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap