Muin is in private beta.Watch the public release announcement —talk to us.
Falaah Falaah AI

Transitions and Post-Submit Callbacks

Animate iframe entry and exit, react to successful submissions with the muin:form-submitted event, and coordinate the host page with a Muin embed.

Muin embeds are plain <iframe> elements — they animate the same way any other DOM node does. This page covers the patterns that hold up well in practice on third-party sites.

Fade on src swap

When you flip an iframe’s src to switch between embedded forms, the new content paints in two ticks (HTML, then form fields). Without an opacity transition the swap looks like a flicker. The /live/form-flavors page uses this pattern:

<iframe
  src={embedUrl(currentPath)}
  className="transition-opacity duration-150 ease-out"
  style={{ opacity: isSwapping ? 0 : 1 }}
  onLoad={() => setIsSwapping(false)}
/>

Trigger setIsSwapping(true) before changing src; clear it on load. 150 ms is long enough to mask the paint and short enough to feel responsive.

Modal entry / exit

The ModalEmbed component uses a <dialog> element with showModal() — the browser handles backdrop, focus trap, and Esc-to-close. Mount the iframe inside the dialog body so it only loads when the modal opens (cuts first-paint cost on the host page):

{isOpen && (
  <iframe src={embedUrl(path)} title="Donate" />
)}

For a smooth open animation, animate transform: scale(0.97 → 1) on the dialog and opacity: 0 → 1 on the backdrop. 200 ms ease-out is the default the demo page uses.

Scroll-triggered surfacing

ScrollPopupEmbed shows the embed at 50% scroll progress. The trigger is IntersectionObserver on a sentinel <div> placed half-way down the page — cheaper than a scroll listener:

const sentinel = document.querySelector("#popup-sentinel")!;
const obs = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) setOpen(true);
}, { threshold: 0 });
obs.observe(sentinel);

Once shown, persist a dismissal in localStorage so a returning visitor doesn’t see the popup on every page load.

Exit-intent

ExitIntentEmbed listens for mouseleave on document.documentElement where e.clientY <= 0 — the cursor crossed the top edge of the viewport. Mobile has no equivalent (no cursor); the demo page suppresses it on viewport widths under 768 px and substitutes a banner instead.

Post-submit callbacks via postMessage

When a Muin form submits successfully, the form posts a message to its parent window:

{
  "type": "muin:form-submitted",
  "formSlug": "sustainer",
  "submissionId": "abc-123",
  "receipt": { "amount": "40.00", "currency": "USD" }
}

Listen for it on your host page:

window.addEventListener("message", (event) => {
  // Always validate origin for security
  if (event.origin !== "https://muin.falaah.ai") return;

  if (event.data?.type === "muin:form-submitted") {
    // Close the modal, fire confetti, redirect, etc.
    setModalOpen(false);
    fireConfetti();
  }
});

The event.origin check is mandatory — without it, any iframe on the page could spoof the message. The receivers wired into ModalEmbed and MicrositeFAB on /live/in-context use this pattern.

Lazy loading

Set loading="lazy" on iframes that are not above the fold. The browser defers loading until the iframe approaches the viewport, which keeps your host page’s Largest Contentful Paint honest:

<iframe src="..." loading="lazy" width="100%" height="700"></iframe>

Sandboxing (optional)

If you want belt-and-suspenders isolation for an embed, add a sandbox attribute. The minimum capabilities Muin forms need:

<iframe
  src="..."
  sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
></iframe>

allow-same-origin is required for the form’s own JS bundle to cooperate with muin.falaah.ai storage; allow-popups is needed for Stripe Elements 3-D Secure flows.

What NOT to animate

  • Iframe height during a paint — re-flowing a loading iframe causes the form’s own measurements to mis-size. Set a fixed height.
  • display: nonedisplay: block — toggles trigger a fresh load every time. Use visibility: hidden + opacity: 0 if the iframe must stay mounted.

Next