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: none↔display: block— toggles trigger a fresh load every time. Usevisibility: hidden+opacity: 0if the iframe must stay mounted.