Call to Action Pill That Expands into a Full Screen Form

A hero with one pill button that turns into the whole screen. Press it and the green shape of the button stretches out to the edges of the frame and becomes a panel with a short pitch on one side and a three field waitlist form on the other. The close button, or the Escape key, shrinks it back into the pill. The demo opens by itself once so the panel is visible in a preview. On phones the pitch sits above the form and the panel scrolls if it needs to.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/expandable-screen)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowRight, Check, X } from "lucide-react";

const perks = ["Small groups of ten riders", "Luggage moved between inns", "A mechanic and a support van"];

const routes = ["Dolomites, six days", "Basque coast, five days", "Douro valley, four days"];

/** Corner radius of the pill and of the opened panel. */
const TRIGGER_RADIUS = 100;
const PANEL_RADIUS = 28;

export default function CallToActionPillThatExpandsIntoAFullScreenForm() {
  const reduce = useReducedMotion();
  const layoutId = useId();
  const [open, setOpen] = useState(false);
  const [sent, setSent] = useState(false);
  const closeButton = useRef<HTMLButtonElement>(null);

  // Open once on load so the preview shows the panel. Remove this effect to start closed.
  useEffect(() => {
    const t = window.setTimeout(() => setOpen(true), reduce ? 0 : 650);
    return () => window.clearTimeout(t);
  }, [reduce]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  const submit = (e: FormEvent) => {
    e.preventDefault();
    setSent(true);
  };

  const field = "mt-1.5 h-11 w-full rounded-xl border border-emerald-900/15 bg-white px-3 text-sm text-emerald-950 placeholder:text-stone-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600";

  return (
    <section className="bg-stone-200 p-3 sm:p-6">
      {/* The frame stands in for the browser window. The panel fills it, not the real page. */}
      <div className="relative mx-auto h-[810px] max-w-6xl overflow-hidden rounded-[32px] bg-amber-50 sm:h-[640px]">
        <div className="flex h-full flex-col items-center justify-center px-6 text-center">
          <p className="text-sm font-semibold uppercase tracking-widest text-emerald-800">Spring departures</p>
          <h2 className="mt-4 max-w-2xl text-4xl font-semibold tracking-tight text-emerald-950 sm:text-6xl">Ride the quiet roads, sleep in good beds.</h2>
          <p className="mt-5 max-w-md text-base leading-7 text-stone-600">Guided cycling weeks through three corners of southern Europe. Places open to the waitlist first.</p>
          <div className="mt-9 h-14">
            <AnimatePresence initial={false}>
              {!open && (
                <motion.div className="relative inline-block">
                  {/* The coloured shape is shared with the panel, so one morphs into the other. */}
                  <motion.div layoutId={layoutId} style={{ borderRadius: TRIGGER_RADIUS }} transition={reduce ? { duration: 0 } : { duration: 0.35, ease: [0.32, 0.72, 0, 1] }} className="absolute inset-0 bg-emerald-900" />
                  <motion.button
                    type="button"
                    aria-haspopup="dialog"
                    onClick={() => {
                      setSent(false);
                      setOpen(true);
                      window.setTimeout(() => closeButton.current?.focus(), 400);
                    }}
                    initial={{ opacity: 0, scale: 0.8 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.8 }}
                    transition={{ delay: reduce ? 0 : 0.2 }}
                    className="relative flex h-14 items-center gap-2 rounded-full px-8 text-base font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50"
                  >
                    Join the waitlist <ArrowRight aria-hidden="true" className="h-4 w-4" />
                  </motion.button>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        <AnimatePresence initial={false}>
          {open && (
            <div className="absolute inset-0 z-10 flex p-2">
              <motion.div
                layoutId={layoutId}
                role="dialog"
                aria-modal="true"
                aria-labelledby={`${layoutId}-title`}
                style={{ borderRadius: PANEL_RADIUS }}
                transition={reduce ? { duration: 0 } : { duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
                className="relative h-full w-full overflow-y-auto bg-emerald-900"
              >
                <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: reduce ? 0 : 0.15, duration: reduce ? 0 : 0.4 }} className="grid min-h-full items-center gap-8 p-6 pt-16 sm:p-12 lg:grid-cols-2 lg:gap-14">
                  <div>
                    <h3 id={`${layoutId}-title`} className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Be first to hear when dates open.</h3>
                    <p className="mt-3 max-w-sm text-base leading-7 text-emerald-100">We write to the waitlist a week before anyone else. No deposit, and you can leave whenever you like.</p>
                    <ul className="mt-6 space-y-3">
                      {perks.map((p) => (
                        <li key={p} className="flex items-center gap-3 text-sm font-medium text-white">
                          <span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-300 text-emerald-950">
                            <Check aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={3} />
                          </span>
                          {p}
                        </li>
                      ))}
                    </ul>
                  </div>

                  <div className="rounded-3xl bg-amber-50 p-5 sm:p-7">
                    {sent ? (
                      <div className="py-10 text-center" role="status">
                        <span className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900 text-white">
                          <Check aria-hidden="true" className="h-6 w-6" />
                        </span>
                        <p className="mt-4 text-xl font-semibold text-emerald-950">You are on the list.</p>
                        <p className="mt-1 text-sm text-stone-600">Look out for a note from Inès in February.</p>
                      </div>
                    ) : (
                      <form onSubmit={submit} className="space-y-4">
                        <div>
                          <label htmlFor={`${layoutId}-name`} className="text-sm font-medium text-emerald-950">Your name</label>
                          <input id={`${layoutId}-name`} type="text" autoComplete="name" placeholder="Rosa Lindqvist" className={field} />
                        </div>
                        <div>
                          <label htmlFor={`${layoutId}-email`} className="text-sm font-medium text-emerald-950">Email</label>
                          <input id={`${layoutId}-email`} type="email" autoComplete="email" placeholder="[email protected]" className={field} />
                        </div>
                        <div>
                          <label htmlFor={`${layoutId}-route`} className="text-sm font-medium text-emerald-950">Route you have your eye on</label>
                          <select id={`${layoutId}-route`} className={field}>
                            {routes.map((r) => (
                              <option key={r}>{r}</option>
                            ))}
                          </select>
                        </div>
                        <button type="submit" className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-900 text-sm font-semibold text-white transition-colors hover:bg-emerald-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50">
                          Save my place <ArrowRight aria-hidden="true" className="h-4 w-4" />
                        </button>
                      </form>
                    )}
                  </div>
                </motion.div>

                <button ref={closeButton} type="button" onClick={() => setOpen(false)} aria-label="Close" className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white sm:right-6 sm:top-6">
                  <X aria-hidden="true" className="h-5 w-5" />
                </button>
              </motion.div>
            </div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/call-to-action-pill-that-expands-into-a-full-screen-form.json

When to use it

How to customize it

Accessibility

Questions

How does the button become the panel?

The coloured background of the pill and the panel are two `motion.div` elements with the same `layoutId`. When one leaves and the other appears, Framer Motion animates position, size and corner radius from the first to the second, so it looks like one shape growing.

Why is the button text a separate layer?

If the label were inside the morphing shape it would be stretched across the screen during the change. Keeping the label on its own layer lets it fade out quickly while only the plain colour stretches.

Why is it inside a frame here?

A preview box cannot be covered by a fixed element, so the demo fills a frame of set height instead. Switching the overlay wrapper to `fixed inset-0` gives the real full screen behaviour.

Does the form send anything?

No. Submitting shows a confirmation message. Replace the `submit` function with your own request.

Should I lock page scrolling while it is open?

On a real page, yes: set `document.body.style.overflow = "hidden"` when `open` is true and clear it on close. It is left out here so the preview never traps the page.

Adapted from Expandable Screen in Cult UI, used under its open-source licence. Licences and credits

Related: Delete Confirmation Dialog with Summary, Newsletter Signup with Benefit List, Call-to-Action Banner on a Brand Colour.

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