-
Ready in five minutes
-
Put one portal at the root. Call it from everywhere else.
+
+
+
RELEASE HISTORY
+
How the API changed
+
+ Apps needed stacked modals, targeted updates, typed close reasons,
+ native overlays, and support for newer gesture APIs.
+
+
+
+
+ {history.map(({ description, href, isoDate, title }) => {
+ const year = new Date(isoDate).getUTCFullYear();
+ return (
+
+
+ {year}
+
+ {formatMilestone(isoDate)}
+
+ {title}
+ {description}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ 9:41
+
+ 5G
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RATING FLOW
+ How was your visit?
+ The rating promise is still pending.
+
+ {[1, 2, 3, 4, 5].map((score) => (
+ {score}
+ ))}
+
+
+
+
+
+
+
GET STARTED
+
Add MagicModalPortal to your app root
+
+ The setup guide mounts the app-root portal and shows how to await a
+ typed HideReturn<T>.
+
+
+
+ Set up the portal
+
+
+
+
-
- Set up the portal
-
-
+
+
);
}
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
index 657406d0..91ee4603 100644
--- a/apps/docs/app/global.css
+++ b/apps/docs/app/global.css
@@ -39,10 +39,6 @@
border-color: var(--color-fd-border);
}
-html {
- scroll-behavior: smooth;
-}
-
body {
font-family:
"Avenir Next",
diff --git a/apps/docs/app/icon.svg b/apps/docs/app/icon.svg
index 9cdc350c..aba18359 100644
--- a/apps/docs/app/icon.svg
+++ b/apps/docs/app/icon.svg
@@ -1,12 +1,10 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx
index 18cce423..b27e004b 100644
--- a/apps/docs/app/layout.tsx
+++ b/apps/docs/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { createMagicDocsMetadata } from "magic-docs";
+import { ProjectMetadataBoundary } from "@/components/project-metadata-boundary";
import { Provider } from "@/components/provider";
import { publicPaths, site } from "@/lib/site";
@@ -26,7 +27,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return (
-
{children}
+
+ {children}
+
);
diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx
index c6953874..f2548514 100644
--- a/apps/docs/app/not-found.tsx
+++ b/apps/docs/app/not-found.tsx
@@ -8,11 +8,10 @@ export default function NotFound() {
return (
- 404 · spell fizzled
- This page escaped the modal stack.
+ 404
+ Page not found
- The API probably moved. Search the new reference or head back to the
- docs.
+ This URL may have moved. Search the reference or return to the docs.
diff --git a/apps/docs/components/home-effects.tsx b/apps/docs/components/home-effects.tsx
new file mode 100644
index 00000000..52baf7e3
--- /dev/null
+++ b/apps/docs/components/home-effects.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useLayoutEffect } from "react";
+
+import { gsap } from "gsap";
+import { ScrollTrigger } from "gsap/ScrollTrigger";
+
+export const HomeEffects = () => {
+ useLayoutEffect(() => {
+ const root = document.querySelector("[data-magic-home]");
+ if (!root) return;
+
+ gsap.registerPlugin(ScrollTrigger);
+
+ const many = (selector: string) => [
+ ...root.querySelectorAll(selector),
+ ];
+ const revealTargets = many("[data-reveal]");
+ const heroTargets = many(
+ ".mm-overline, .mm-hero h1, .mm-hero-lede, .mm-hero-actions, .mm-hero-copy > .mh-install-command",
+ );
+ const media = gsap.matchMedia();
+
+ const context = gsap.context(() => {
+ media.add(
+ "(prefers-reduced-motion: reduce)",
+ () => {
+ gsap.set([...heroTargets, ...revealTargets], {
+ clearProps:
+ "opacity,transform,visibility,filter,clipPath,willChange",
+ });
+ },
+ root,
+ );
+
+ media.add(
+ "(min-width: 701px) and (prefers-reduced-motion: no-preference)",
+ () => {
+ gsap
+ .timeline({ defaults: { ease: "power3.out" } })
+ .from(heroTargets, {
+ autoAlpha: 0,
+ duration: 0.7,
+ stagger: 0.07,
+ y: 18,
+ })
+ .from(
+ ".mm-flow-four",
+ {
+ autoAlpha: 0,
+ duration: 0.9,
+ rotation: -6,
+ scale: 0.92,
+ },
+ 0.06,
+ )
+ .from(
+ ".mm-flow-code",
+ {
+ autoAlpha: 0,
+ duration: 0.82,
+ ease: "expo.out",
+ x: -24,
+ },
+ 0.16,
+ )
+ .from(
+ ".mm-native-stage",
+ {
+ autoAlpha: 0,
+ duration: 0.9,
+ ease: "expo.out",
+ x: 24,
+ y: 16,
+ },
+ 0.22,
+ );
+ },
+ root,
+ );
+
+ media.add(
+ "(min-width: 701px) and (prefers-reduced-motion: no-preference)",
+ () => {
+ gsap.set(revealTargets, {
+ opacity: 0,
+ y: 24,
+ });
+
+ ScrollTrigger.batch(revealTargets, {
+ interval: 0.08,
+ once: true,
+ onEnter: (targets) => {
+ gsap.to(targets, {
+ duration: 0.66,
+ ease: "power3.out",
+ opacity: 1,
+ stagger: 0.06,
+ y: 0,
+ });
+ },
+ start: "top 88%",
+ });
+ },
+ root,
+ );
+
+ media.add(
+ "(max-width: 700px)",
+ () => {
+ gsap.set(
+ [
+ ...heroTargets,
+ ...revealTargets,
+ ".mm-flow-four",
+ ".mm-flow-code",
+ ".mm-native-stage",
+ ],
+ {
+ clearProps:
+ "opacity,transform,visibility,filter,clipPath,willChange",
+ },
+ );
+ },
+ root,
+ );
+ }, root);
+
+ return () => {
+ media.revert();
+ context.revert();
+ };
+ }, []);
+
+ return null;
+};
diff --git a/apps/docs/components/install-command.tsx b/apps/docs/components/install-command.tsx
new file mode 100644
index 00000000..9c2b89ab
--- /dev/null
+++ b/apps/docs/components/install-command.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+
+import { Check, Copy } from "lucide-react";
+
+const command = "pnpm add react-native-magic-modal";
+type CopyState = "copied" | "failed" | "idle";
+
+const fallbackCopy = () => {
+ const input = document.createElement("textarea");
+ input.value = command;
+ input.setAttribute("readonly", "");
+ input.style.position = "fixed";
+ input.style.opacity = "0";
+ try {
+ document.body.append(input);
+ input.select();
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ input.remove();
+ }
+};
+
+export const InstallCommand = ({ compact = false }: { compact?: boolean }) => {
+ const [copyState, setCopyState] = useState("idle");
+ const resetTimer = useRef | undefined>(
+ undefined,
+ );
+
+ const copy = useCallback(async () => {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(command);
+ } else if (!fallbackCopy()) {
+ throw new Error("Copy command was rejected");
+ }
+ setCopyState("copied");
+ } catch {
+ setCopyState(fallbackCopy() ? "copied" : "failed");
+ }
+ clearTimeout(resetTimer.current);
+ resetTimer.current = setTimeout(() => setCopyState("idle"), 1800);
+ }, []);
+
+ let copyFeedback = (
+ <>
+
+ copy
+ >
+ );
+ if (copyState === "copied") {
+ copyFeedback = (
+ <>
+
+ copied
+ >
+ );
+ } else if (copyState === "failed") {
+ copyFeedback = <>select>;
+ }
+
+ return (
+
+
+ $
+
+ {command}
+
+ {copyFeedback}
+
+
+ );
+};
diff --git a/apps/docs/components/live-package-demo.tsx b/apps/docs/components/live-package-demo.tsx
new file mode 100644
index 00000000..f4a043cc
--- /dev/null
+++ b/apps/docs/components/live-package-demo.tsx
@@ -0,0 +1,720 @@
+"use client";
+
+/* eslint-disable react-perf/jsx-no-new-function-as-prop -- Modal choices close over the value returned to the caller. */
+
+import type {
+ HideReturn,
+ ModalChildren,
+ NewConfigProps,
+} from "react-native-magic-modal";
+
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ MagicModalHideReason,
+ MagicModalPortal,
+ magicModal,
+ useMagicModal,
+} from "react-native-magic-modal";
+
+type StackEntry = {
+ id: string;
+ label: string;
+};
+
+type TimelineEntry = StackEntry & {
+ result: string;
+};
+
+type ShowTracked = (
+ label: string,
+ component: ModalChildren,
+ config?: NewConfigProps,
+) => ReturnType>;
+
+const modalConfig = {
+ animationInTiming: 180,
+ animationOutTiming: 140,
+ backdropColor: "rgba(13, 12, 10, 0.84)",
+ swipeDirection: undefined,
+} satisfies NewConfigProps;
+
+const wait = (milliseconds: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, milliseconds);
+ });
+
+const describeResult = (result: HideReturn) => {
+ if (result.reason !== MagicModalHideReason.INTENTIONAL_HIDE) {
+ return result.reason;
+ }
+
+ if (result.data === undefined) {
+ return "RESOLVED";
+ }
+
+ return `RETURNED ${String(result.data).toUpperCase()}`;
+};
+
+const ModalFrame = ({
+ children,
+ eyebrow,
+ surface = "mobile",
+ title,
+}: {
+ children: React.ReactNode;
+ eyebrow: string;
+ surface?: "mobile" | "web";
+ title: string;
+}) => (
+
+
+ {surface === "mobile" ? (
+ <>
+ 9:41
+
+ 5G
+ >
+ ) : (
+ <>
+ Release dashboard
+ app.example.com/releases
+ >
+ )}
+
+
+
+
+ {eyebrow}
+
{title}
+
+
+ {children}
+
+);
+
+const ScorePrompt = () => {
+ const { hide } = useMagicModal();
+
+ return (
+
+ Pick a score. The caller is waiting for this value.
+
+ {[1, 2, 3, 4, 5].map((score) => (
+ hide(score)}
+ type="button"
+ >
+ {score}
+
+ ))}
+
+
+ );
+};
+
+const NotificationPrompt = () => {
+ const { hide } = useMagicModal<"reviewed">();
+
+ return (
+
+
+ This opened above the rating prompt. Close it and the rating promise
+ stays pending.
+
+
+ STACK POSITION
+ Top entry
+
+ hide("reviewed")}
+ type="button"
+ >
+ Review later
+
+
+ );
+};
+
+const FeedbackPrompt = () => {
+ const { hide } = useMagicModal();
+
+ return (
+
+
+ The same caller handles this branch after the rating promise resolves.
+
+
+ {["Too slow", "Hard to follow", "Something broke"].map((reason) => (
+ hide(reason)} type="button">
+ {reason}
+ return value
+
+ ))}
+
+
+ );
+};
+
+const StorePrompt = () => {
+ const { hide } = useMagicModal<"later" | "open">();
+
+ return (
+
+
+ A high score reached this branch without adding state to the screen.
+
+
+ hide("later")} type="button">
+ Maybe later
+
+ hide("open")}
+ type="button"
+ >
+ Open the store
+
+
+
+ );
+};
+
+const ThanksPrompt = ({ detail }: { detail: string }) => {
+ const { hide } = useMagicModal();
+
+ return (
+
+ {detail}
+ hide(undefined)}
+ type="button"
+ >
+ Done
+
+
+ );
+};
+
+const UploadPrompt = ({ progress }: { progress: number }) => {
+ const { hide } = useMagicModal<"cancelled" | "done">();
+ const complete = progress === 100;
+
+ return (
+
+ update() replaces the component in the open entry.
+
+
+ {progress}%
+ handle.update()
+
+ hide(complete ? "done" : "cancelled")}
+ type="button"
+ >
+ {complete ? "Done" : "Cancel upload"}
+
+
+ );
+};
+
+const createThanksPrompt = (detail: string) => {
+ const FlowThanksPrompt = () => ;
+
+ return FlowThanksPrompt;
+};
+
+export const LivePackageDemo = () => {
+ const rootRef = useRef(null);
+ const restoreFocusRef = useRef(null);
+ const [activeEntries, setActiveEntries] = useState([]);
+ const [timeline, setTimeline] = useState([]);
+ const [ratingRunning, setRatingRunning] = useState(false);
+ const [updateRunning, setUpdateRunning] = useState(false);
+
+ const modalOpen = activeEntries.length > 0;
+ const reversedStack = useMemo(
+ () => [...activeEntries].reverse(),
+ [activeEntries],
+ );
+
+ const record = useCallback((entry: TimelineEntry) => {
+ setTimeline((current) => [entry, ...current].slice(0, 6));
+ }, []);
+
+ const showTracked: ShowTracked = useCallback(
+ (label: string, component: ModalChildren, config?: NewConfigProps) => {
+ const handle = magicModal.show(component, config);
+ const entry = { id: handle.modalID, label };
+
+ setActiveEntries((current) => [...current, entry]);
+ setTimeline((current) =>
+ [{ ...entry, result: "PENDING" }, ...current].slice(0, 6),
+ );
+
+ void handle.promise.then((result) => {
+ setActiveEntries((current) =>
+ current.filter(({ id }) => id !== handle.modalID),
+ );
+ setTimeline((current) =>
+ current.map((item) =>
+ item.id === handle.modalID
+ ? { ...item, result: describeResult(result) }
+ : item,
+ ),
+ );
+ });
+
+ return handle;
+ },
+ [],
+ );
+
+ const openNotification = useCallback(async () => {
+ const notification = showTracked<"reviewed">(
+ "Payment notification",
+ NotificationPrompt,
+ modalConfig,
+ );
+
+ await notification.promise;
+ }, [showTracked]);
+
+ const runRatingFlow = useCallback(
+ async (stackNotification: boolean) => {
+ if (ratingRunning || updateRunning) {
+ return;
+ }
+
+ setRatingRunning(true);
+ restoreFocusRef.current ??=
+ document.activeElement instanceof HTMLElement &&
+ document.activeElement !== document.body
+ ? document.activeElement
+ : null;
+ const rating = showTracked(
+ "Rating prompt",
+ ScorePrompt,
+ modalConfig,
+ );
+
+ if (stackNotification) {
+ await wait(320);
+ await openNotification();
+ }
+
+ const ratingResult = await rating.promise;
+
+ if (ratingResult.reason !== MagicModalHideReason.INTENTIONAL_HIDE) {
+ record({
+ id: `flow-${rating.modalID}`,
+ label: "Rating flow",
+ result: `STOPPED BY ${ratingResult.reason}`,
+ });
+ setRatingRunning(false);
+ return;
+ }
+
+ let detail: string;
+
+ if (ratingResult.data <= 3) {
+ const feedback = showTracked(
+ "Feedback prompt",
+ FeedbackPrompt,
+ modalConfig,
+ );
+ const feedbackResult = await feedback.promise;
+
+ detail =
+ feedbackResult.reason === MagicModalHideReason.INTENTIONAL_HIDE
+ ? `Feedback saved: ${feedbackResult.data}.`
+ : "The feedback prompt closed.";
+ } else {
+ const store = showTracked<"later" | "open">(
+ "Store prompt",
+ StorePrompt,
+ modalConfig,
+ );
+ const storeResult = await store.promise;
+
+ detail =
+ storeResult.reason === MagicModalHideReason.INTENTIONAL_HIDE &&
+ storeResult.data === "open"
+ ? "Store review selected."
+ : "Store review skipped.";
+ }
+
+ const thanks = showTracked(
+ "Thank-you prompt",
+ createThanksPrompt(detail),
+ modalConfig,
+ );
+ await thanks.promise;
+ setRatingRunning(false);
+ },
+ [openNotification, ratingRunning, record, showTracked, updateRunning],
+ );
+
+ const runUpdateDemo = useCallback(async () => {
+ if (ratingRunning || updateRunning) {
+ return;
+ }
+
+ setUpdateRunning(true);
+ restoreFocusRef.current ??=
+ document.activeElement instanceof HTMLElement &&
+ document.activeElement !== document.body
+ ? document.activeElement
+ : null;
+ const handle = showTracked<"cancelled" | "done">(
+ "Upload progress",
+ () => ,
+ modalConfig,
+ );
+ let resolved = false;
+
+ void handle.promise.then(() => {
+ resolved = true;
+ });
+
+ await [34, 67, 100].reduce(
+ (sequence, progress) =>
+ sequence.then(async () => {
+ await wait(560);
+
+ if (resolved) {
+ return;
+ }
+
+ handle.update(() => );
+ record({
+ id: `update-${handle.modalID}-${progress}`,
+ label: "Upload content",
+ result: `UPDATED TO ${progress}%`,
+ });
+ }),
+ Promise.resolve(),
+ );
+
+ await handle.promise;
+ setUpdateRunning(false);
+ }, [ratingRunning, record, showTracked, updateRunning]);
+
+ useEffect(() => {
+ const root = rootRef.current;
+
+ if (!root) {
+ return;
+ }
+
+ const home = root.closest(".magic-home");
+ const liveSection = root.closest(".mm-live");
+ const stageContent = root.querySelector(
+ ".mm-live-package-content",
+ );
+ const inertTargets = [
+ ...(home
+ ? [...home.children].filter((element) => element !== liveSection)
+ : []),
+ ...(liveSection
+ ? [...liveSection.children].filter((element) => !element.contains(root))
+ : []),
+ ...(stageContent ? [stageContent] : []),
+ ].filter(
+ (element): element is HTMLElement => element instanceof HTMLElement,
+ );
+
+ if (!modalOpen) {
+ return;
+ }
+
+ restoreFocusRef.current ??=
+ document.activeElement instanceof HTMLElement &&
+ document.activeElement !== document.body
+ ? document.activeElement
+ : null;
+
+ for (const element of inertTargets) {
+ element.inert = true;
+ }
+
+ const previousOverflow = document.body.style.overflow;
+ const previousRootOverflow = document.documentElement.style.overflow;
+ document.body.style.overflow = "hidden";
+ document.documentElement.style.overflow = "hidden";
+
+ return () => {
+ for (const element of inertTargets) {
+ element.inert = false;
+ }
+
+ document.body.style.overflow = previousOverflow;
+ document.documentElement.style.overflow = previousRootOverflow;
+ };
+ }, [modalOpen]);
+
+ useEffect(() => {
+ const root = rootRef.current;
+
+ if (!root) {
+ return;
+ }
+
+ const syncDialogs = () => {
+ const dialogs = [...root.querySelectorAll("dialog")];
+ const topDialogIndex = dialogs.length - 1;
+
+ for (const [index, dialog] of dialogs.entries()) {
+ const hidden = index !== topDialogIndex;
+ dialog.inert = hidden;
+
+ if (hidden) {
+ dialog.setAttribute("aria-hidden", "true");
+ } else {
+ dialog.removeAttribute("aria-hidden");
+ }
+ }
+
+ dialogs.at(-1)?.focus();
+ };
+
+ const dialogObserver = new MutationObserver(syncDialogs);
+ dialogObserver.observe(root, { childList: true, subtree: true });
+
+ const focusTopDialog = window.setTimeout(syncDialogs, 30);
+
+ if (!modalOpen && !ratingRunning && !updateRunning) {
+ restoreFocusRef.current?.focus();
+ restoreFocusRef.current = null;
+ }
+
+ return () => {
+ dialogObserver.disconnect();
+ window.clearTimeout(focusTopDialog);
+ };
+ }, [activeEntries, modalOpen, ratingRunning, updateRunning]);
+
+ useEffect(() => {
+ if (!modalOpen) {
+ return;
+ }
+
+ const trapFocus = (event: KeyboardEvent) => {
+ if (event.key !== "Tab") {
+ return;
+ }
+
+ const root = rootRef.current;
+ const dialogs = root?.querySelectorAll("dialog");
+ const dialog = dialogs?.item((dialogs?.length ?? 1) - 1);
+
+ if (!dialog) {
+ return;
+ }
+
+ const focusable = [
+ ...dialog.querySelectorAll(
+ 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ ),
+ ];
+
+ if (focusable.length === 0) {
+ event.preventDefault();
+ dialog.focus();
+ return;
+ }
+
+ const [first] = focusable;
+ const last = focusable.at(-1);
+
+ if (
+ event.shiftKey &&
+ (document.activeElement === first || document.activeElement === dialog)
+ ) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ };
+
+ document.addEventListener("keydown", trapFocus);
+ return () => {
+ document.removeEventListener("keydown", trapFocus);
+ };
+ }, [modalOpen]);
+
+ return (
+
+
+
+
+ ACTUAL PACKAGE
+ Expo / iOS / Android / Web
+
+
+ {activeEntries.length}
+
+ {activeEntries.length === 1 ? "open entry" : "open entries"}
+
+
+
+
+
+
+
ONE PORTAL ON THIS PAGE
+
Stack a notification over the rating prompt
+
+ Start the rating flow in the phone frame, or run update() in the
+ web panel. This page mounts one MagicModalPortal for both.
+
+
+ {
+ restoreFocusRef.current = event.currentTarget;
+ void runRatingFlow(false);
+ }}
+ type="button"
+ >
+ {ratingRunning ? "Rating flow open" : "Start rating flow"}
+
+ {
+ restoreFocusRef.current = event.currentTarget;
+ void runRatingFlow(true);
+ }}
+ type="button"
+ >
+ Stack a notification
+
+ {
+ restoreFocusRef.current = event.currentTarget;
+ void runUpdateDemo();
+ }}
+ type="button"
+ >
+ {updateRunning ? "Upload in progress" : "Start web upload"}
+
+
+
+
+
+
+ CALLER
+ rating-flow.ts
+
+
+
+ const result = await magicModal
+ {"\n "}.show(RatingPrompt){"\n "}.promise
+
+
+
+ 1 portal
+ 1 result per entry
+
+
+
+
+
+
+
+
+ {reversedStack.length === 0 ? (
+
+ The stack is empty.
+ Modal content mounts after show() runs.
+
+ ) : (
+ reversedStack.map((entry, index) => (
+
+
{reversedStack.length - index}
+
+ {entry.label}
+ {entry.id.slice(0, 7)}
+
+
{index === 0 ? "VISIBLE" : "WAITING"}
+
+ ))
+ )}
+
+
+
+
+
+
+ {timeline.length === 0 ? (
+
+ No calls yet.
+ Resolved values appear here.
+
+ ) : (
+ timeline.map((entry) => (
+
+
+ {entry.label}
+
+
{entry.result}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/docs/components/magic-mark.tsx b/apps/docs/components/magic-mark.tsx
index 066a8790..bffeecc5 100644
--- a/apps/docs/components/magic-mark.tsx
+++ b/apps/docs/components/magic-mark.tsx
@@ -10,28 +10,40 @@ export const MagicMark = ({
className={className}
fill="none"
height={size}
- viewBox="0 0 40 40"
+ viewBox="0 0 48 48"
width={size}
>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
);
diff --git a/apps/docs/components/modal-playground.tsx b/apps/docs/components/modal-playground.tsx
deleted file mode 100644
index 4eacc4b9..00000000
--- a/apps/docs/components/modal-playground.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-
-import { Check, ChevronRight, Sparkles, X } from "lucide-react";
-
-export const ModalPlayground = () => {
- const [isOpen, setIsOpen] = useState(true);
- const [result, setResult] = useState<"waiting" | "confirmed" | "cancelled">(
- "waiting",
- );
-
- const show = useCallback(() => {
- setResult("waiting");
- setIsOpen(true);
- }, []);
-
- const finish = useCallback((next: "confirmed" | "cancelled") => {
- setResult(next);
- setIsOpen(false);
- }, []);
-
- const cancel = useCallback(() => finish("cancelled"), [finish]);
- const confirm = useCallback(() => finish("confirmed"), [finish]);
-
- return (
-
-
-
-
-
-
-
-
checkout-flow.tsx
-
-
- live
-
-
-
-
-
-
- const result{" "}
- = await
-
-
- magicModal.show
- <Confirmation> (
-
-
- () => <
- ConfirmPurchase />
-
-
).promise;
-
-
- if (result.reason ===
-
-
-
- MagicModalHideReason.INTENTIONAL_HIDE
-
- ) {"{"}
-
-
- completeOrder (result.data);
-
-
{"}"}
-
-
-
- {result === "waiting" && "Promise waiting for the modal…"}
- {result === "confirmed" && "Resolved with { confirmed: true }"}
- {result === "cancelled" && "Resolved with BACKDROP_PRESS"}
-
-
-
-
-
-
-
-
- Magic Shop
-
-
-
M
-
- Magic Pass
- $24 / lifetime
-
-
-
- Show modal
-
-
-
-
-
-
-
-
-
-
-
-
- Confirm purchase?
- Your modal can return fully typed data to the caller.
-
- Confirm · $24
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/docs/components/origin-flow.tsx b/apps/docs/components/origin-flow.tsx
new file mode 100644
index 00000000..564ce575
--- /dev/null
+++ b/apps/docs/components/origin-flow.tsx
@@ -0,0 +1,437 @@
+"use client";
+
+import type { MouseEvent } from "react";
+
+import { useCallback, useState } from "react";
+
+import {
+ ArrowUpRight,
+ Battery,
+ Check,
+ Heart,
+ RotateCcw,
+ Signal,
+ Star,
+ Wifi,
+} from "lucide-react";
+
+type Branch = "feedback" | "store" | null;
+type FlowStep = "rating" | "feedback" | "store" | "thanks" | "done";
+type ResultReason = "GLOBAL_HIDE_ALL" | "INTENTIONAL_HIDE";
+
+const scoreLabels = ["0", "1", "2", "3", "4", "5"] as const;
+const feedbackOptions = [
+ "Too many steps",
+ "Hard to use",
+ "Something else",
+] as const;
+
+const stepIndex: Record = {
+ rating: 0,
+ feedback: 1,
+ store: 1,
+ thanks: 2,
+ done: 3,
+};
+
+const activeEntryByStep: Record = {
+ rating: "RatingModal",
+ feedback: "FeedbackModal",
+ store: "StoreReviewModal",
+ thanks: "ThanksModal",
+ done: "stack empty",
+};
+
+const liveCodeByStep: Record = {
+ rating: "await show(RatingModal)",
+ feedback: "await show(FeedbackModal)",
+ store: "await show(StoreReviewModal)",
+ thanks: "await show(ThanksModal)",
+ done: "return rating",
+};
+
+const activeCodeClass = (activeIndex: number, lineIndex: number) =>
+ activeIndex === lineIndex ? "is-active" : "";
+
+const getBranchSheetClass = (
+ branch: Branch,
+ expectedBranch: Exclude,
+ step: FlowStep,
+) => {
+ if (step === expectedBranch) return "is-active";
+ if (branch === expectedBranch) return "is-past";
+ return "is-future";
+};
+
+const getResultCopy = (reason: ResultReason) => {
+ if (reason === "INTENTIONAL_HIDE") {
+ return {
+ label: "RESOLVED",
+ message: "show(...).promise resolved.",
+ };
+ }
+ return {
+ label: "CLEARED",
+ message: "All entries resolved with GLOBAL_HIDE_ALL.",
+ };
+};
+
+const getResultPayload = (
+ reason: ResultReason,
+ score: number | null,
+): string => {
+ if (reason === "INTENTIONAL_HIDE" && score !== null) {
+ return `{\n reason: "${reason}",\n data: { score: ${score} }\n}`;
+ }
+ return `{\n reason: "${reason}"\n}`;
+};
+
+export const OriginFlow = () => {
+ const [branch, setBranch] = useState(null);
+ const [feedback, setFeedback] = useState("Too many steps");
+ const [reason, setReason] = useState("INTENTIONAL_HIDE");
+ const [score, setScore] = useState(null);
+ const [step, setStep] = useState("rating");
+
+ const reset = useCallback(() => {
+ setBranch(null);
+ setFeedback("Too many steps");
+ setReason("INTENTIONAL_HIDE");
+ setScore(null);
+ setStep("rating");
+ }, []);
+
+ const chooseScore = useCallback((event: MouseEvent) => {
+ const nextScore = Number(event.currentTarget.dataset.score);
+ const nextBranch = nextScore < 4 ? "feedback" : "store";
+ setScore(nextScore);
+ setBranch(nextBranch);
+ setReason("INTENTIONAL_HIDE");
+ setStep(nextBranch);
+ }, []);
+
+ const chooseFeedback = useCallback((event: MouseEvent) => {
+ setFeedback(event.currentTarget.dataset.feedback ?? "Something else");
+ }, []);
+
+ const finishBranch = useCallback(() => setStep("thanks"), []);
+ const finishFlow = useCallback(() => {
+ setReason("INTENTIONAL_HIDE");
+ setStep("done");
+ }, []);
+ const dismissAll = useCallback(() => {
+ setReason("GLOBAL_HIDE_ALL");
+ setStep("done");
+ }, []);
+
+ const activeIndex = stepIndex[step];
+ const activeEntry = activeEntryByStep[step];
+ const feedbackClass = getBranchSheetClass(branch, "feedback", step);
+ const isDone = step === "done";
+ const ratingClass = step === "rating" ? "is-active" : "is-past";
+ const resultCopy = getResultCopy(reason);
+ const resultPayload = getResultPayload(reason, score);
+ const storeClass = getBranchSheetClass(branch, "store", step);
+ const thanksClass = step === "thanks" ? "is-active" : "is-future";
+
+ return (
+
+
+ 4
+
+
+
+
+
+
+
+
+
+ rating-flow.tsx
+
+
+
+
+
+ 01
+ const rating = await magicModal
+ {"\n"}
+
+ {" "}.show<RatingAnswer>(RatingModal).promise;
+
+
+ 02
+ await magicModal.show(
+ {"\n"}
+
+ {" "}rating.data.score < 4{"\n"}
+
+ {" "}? FeedbackModal : StoreReviewModal
+ {"\n"}
+
+ ).promise;
+
+
+ 03
+ await magicModal
+ {"\n"}
+
+ {" "}.show(ThanksModal).promise;
+
+
+ 04
+ return rating;
+
+
+
+
+
+ {String(activeIndex + 1).padStart(2, "0")}
+ {liveCodeByStep[step]}
+
+
+
+
+ {isDone ? "resolved" : "promise pending"}
+
+
+
+
+
+
+
+
+
+
+
+
+ MagicModalPortal
+
+ {isDone ? "STACK: EMPTY" : "STACK: 1 ENTRY"}
+
+
+
+
+ 9:41
+
+
+
+
+
+
+
+ Moments
+
+
+
+
+
+
+
+ GT
+
+ Gabriel
+ just now
+
+
+
+ first like
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 01
+ {activeEntry}
+
+ Rate the app
+ How would you rate the app from zero to five?
+
+ {scoreLabels.map((value) => (
+
+ {value}
+
+ ))}
+
+
+ Scores 0 through 3 open feedback. Scores 4 and 5 open the store
+ prompt.
+
+
+
+
+
+
+ 02A
+ score < 4
+
+ What got in the way?
+ Choose the closest reason.
+
+ {feedbackOptions.map((value) => (
+
+ {value}
+
+ ))}
+
+
+ Send feedback
+
+
+
+
+
+
+ 02B
+ score >= 4
+
+
+
+
+ Would you leave a store rating?
+ Scores four and five reach this branch.
+
+
+ Not now
+
+
+ Open the store
+
+
+
+
+
+
+
+
+ 03
+ last modal
+
+
+
+
+ Thanks.
+ The last modal closes and the caller resumes.
+
+ Resolve the promise
+
+
+
+
+
{resultCopy.label}
+
{resultCopy.message}
+
+ {resultPayload}
+
+
+ Run it again
+
+
+
+
+
+
+
+
+ {activeEntry}
+
+
+ hideAll()
+
+
+
+
+ );
+};
diff --git a/apps/docs/components/practical-examples.tsx b/apps/docs/components/practical-examples.tsx
new file mode 100644
index 00000000..07f8e9dc
--- /dev/null
+++ b/apps/docs/components/practical-examples.tsx
@@ -0,0 +1,418 @@
+"use client";
+
+import type { CSSProperties, MouseEvent } from "react";
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { gsap } from "gsap";
+import { ArrowRight, Check, Upload } from "lucide-react";
+
+type ExampleID = "confirm" | "follow-up" | "update";
+type ExamplePhase =
+ | "account-picked"
+ | "complete"
+ | "details"
+ | "ready"
+ | "resolved"
+ | "uploaded"
+ | "uploading";
+
+type Example = {
+ code: string;
+ file: string;
+ id: ExampleID;
+ label: string;
+ note: string;
+ type: ExampleID;
+};
+
+const confirmExample: Example = {
+ code: `const result = await magicModal
+ .show(() => (
+
+ ))
+ .promise;
+
+if (
+ result.reason === INTENTIONAL_HIDE &&
+ result.data.confirmed
+) {
+ await deletePost(postID);
+}`,
+ file: "delete-post.tsx",
+ id: "confirm",
+ label: "Await a decision",
+ note: "Press the modal action to resolve the promise.",
+ type: "confirm",
+};
+
+const examples: Example[] = [
+ confirmExample,
+ {
+ code: `const account = await magicModal
+ .show(() => )
+ .promise;
+
+if (account.reason === INTENTIONAL_HIDE) {
+ await magicModal
+ .show(() => (
+
+ ))
+ .promise;
+}`,
+ file: "account-flow.tsx",
+ id: "follow-up",
+ label: "Open a follow-up",
+ note: "The first result decides which modal opens next.",
+ type: "follow-up",
+ },
+ {
+ code: `const { update, promise } = magicModal.show(
+ () =>
+);
+
+upload.onProgress((progress) => {
+ update(() => (
+
+ ));
+});
+
+await promise;`,
+ file: "upload.tsx",
+ id: "update",
+ label: "Update in place",
+ note: "Progress replaces the active component without closing it.",
+ type: "update",
+ },
+];
+
+const findExample = (id: ExampleID) =>
+ examples.find((example) => example.id === id) ?? confirmExample;
+
+const nextExample: Record, ExampleID> = {
+ confirm: "follow-up",
+ "follow-up": "update",
+};
+
+const getPreview = (
+ activeID: ExampleID,
+ phase: ExamplePhase,
+ progress: number,
+) => {
+ if (activeID === "confirm") {
+ if (phase === "resolved") {
+ return {
+ action: "Opening the next example",
+ body: "The confirmed result triggered deletePost(postID).",
+ disabled: true,
+ result: "{ confirmed: true }",
+ title: "Promise resolved",
+ };
+ }
+ return {
+ action: "Delete post",
+ body: "This action cannot be undone.",
+ disabled: false,
+ result: "Promise pending",
+ title: "Delete this post?",
+ };
+ }
+
+ if (activeID === "follow-up") {
+ if (phase === "account-picked") {
+ return {
+ action: "Opening AccountDetails",
+ body: "The first promise returned the selected account.",
+ disabled: true,
+ result: '{ account: "Personal" }',
+ title: "Account selected",
+ };
+ }
+ if (phase === "details" || phase === "complete") {
+ return {
+ action: phase === "complete" ? "Opening upload" : "Done",
+ body: "The account result opened this modal.",
+ disabled: phase === "complete",
+ result:
+ phase === "complete" ? "Flow complete" : "Follow-up promise pending",
+ title: "Personal account",
+ };
+ }
+ return {
+ action: "Continue",
+ body: "Personal account, ending in 4821",
+ disabled: false,
+ result: "Promise pending",
+ title: "Choose an account",
+ };
+ }
+
+ if (phase === "uploading") {
+ return {
+ action: `Uploading ${progress}%`,
+ body: "update() remounts the sheet at each checkpoint.",
+ disabled: true,
+ result: `${progress}% | promise pending`,
+ title: "Upload in progress",
+ };
+ }
+ if (phase === "uploaded") {
+ return {
+ action: "Close modal",
+ body: "The upload finished. The same promise is still pending.",
+ disabled: false,
+ result: "100% | promise pending",
+ title: "Upload complete",
+ };
+ }
+ if (phase === "complete") {
+ return {
+ action: "Replay the flow",
+ body: "Closing the modal resolved the original promise.",
+ disabled: false,
+ result: "Promise resolved",
+ title: "Flow complete",
+ };
+ }
+ return {
+ action: "Start upload",
+ body: "video-final.mp4",
+ disabled: false,
+ result: "0% | promise pending",
+ title: "Ready to upload",
+ };
+};
+
+export const PracticalExamples = () => {
+ const [activeID, setActiveID] = useState("confirm");
+ const [phase, setPhase] = useState("ready");
+ const [progress, setProgress] = useState(0);
+ const panelRef = useRef(null);
+ const active = findExample(activeID);
+ const preview = getPreview(activeID, phase, progress);
+
+ const openExample = useCallback((id: ExampleID) => {
+ setActiveID(id);
+ setPhase("ready");
+ setProgress(0);
+ }, []);
+
+ const selectExample = useCallback(
+ (event: MouseEvent) => {
+ openExample(event.currentTarget.dataset.example as ExampleID);
+ },
+ [openExample],
+ );
+
+ const runPreview = useCallback(() => {
+ if (activeID === "confirm") {
+ setPhase("resolved");
+ return;
+ }
+ if (activeID === "follow-up") {
+ if (phase === "ready") setPhase("account-picked");
+ if (phase === "details") setPhase("complete");
+ return;
+ }
+ if (phase === "ready") {
+ setProgress(0);
+ setPhase("uploading");
+ return;
+ }
+ if (phase === "uploaded") {
+ setPhase("complete");
+ return;
+ }
+ if (phase === "complete") openExample("confirm");
+ }, [activeID, openExample, phase]);
+
+ useEffect(() => {
+ if (activeID === "confirm" && phase === "resolved") {
+ const timeout = window.setTimeout(
+ () => openExample(nextExample.confirm),
+ 900,
+ );
+ return () => window.clearTimeout(timeout);
+ }
+ if (activeID === "follow-up" && phase === "account-picked") {
+ const timeout = window.setTimeout(() => setPhase("details"), 750);
+ return () => window.clearTimeout(timeout);
+ }
+ if (activeID === "follow-up" && phase === "complete") {
+ const timeout = window.setTimeout(
+ () => openExample(nextExample["follow-up"]),
+ 900,
+ );
+ return () => window.clearTimeout(timeout);
+ }
+ }, [activeID, openExample, phase]);
+
+ const progressStyle = useMemo(
+ () =>
+ ({
+ "--mm-example-progress": `${progress}%`,
+ }) as CSSProperties,
+ [progress],
+ );
+
+ useEffect(() => {
+ if (activeID !== "update" || phase !== "uploading") return;
+
+ const interval = window.setInterval(() => {
+ setProgress((current) => {
+ const next = Math.min(current + 8, 100);
+ if (next === 100) {
+ window.clearInterval(interval);
+ window.setTimeout(() => setPhase("uploaded"), 260);
+ }
+ return next;
+ });
+ }, 110);
+
+ return () => window.clearInterval(interval);
+ }, [activeID, phase]);
+
+ useLayoutEffect(() => {
+ if (!panelRef.current) return;
+
+ const reduceMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ if (reduceMotion) return;
+
+ const context = gsap.context(() => {
+ gsap
+ .timeline({ defaults: { ease: "power3.out" } })
+ .fromTo(
+ "[data-example-code]",
+ { autoAlpha: 0, x: -14 },
+ { autoAlpha: 1, duration: 0.36, x: 0 },
+ )
+ .fromTo(
+ "[data-example-preview]",
+ { autoAlpha: 0, scale: 0.98, x: 18 },
+ {
+ autoAlpha: 1,
+ duration: 0.45,
+ scale: 1,
+ x: 0,
+ },
+ 0.04,
+ );
+ }, panelRef);
+
+ return () => context.revert();
+ }, [activeID]);
+
+ return (
+
+
+
+
+
+ {examples.map((example, index) => (
+
+ {String(index + 1).padStart(2, "0")}
+ {example.label}
+ {example.note}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {active.file}
+
+
+ {active.code}
+
+
+
+
+
+ MagicModalPortal
+ {phase === "complete" ? "stack empty" : "1 entry"}
+
+
+
+
+ {active.type === "update" ? (
+
+ ) : (
+
+ )}
+
+ {preview.title}
+ {preview.body}
+ {active.type === "update" && (
+
+
+
+ )}
+
+ {preview.action}
+
+
+
+ RESULT
+ {preview.result}
+
+
+
+
+
+ );
+};
diff --git a/apps/docs/components/project-metadata-boundary.tsx b/apps/docs/components/project-metadata-boundary.tsx
new file mode 100644
index 00000000..6b859ce8
--- /dev/null
+++ b/apps/docs/components/project-metadata-boundary.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+import { getProjectMetadataSnapshot } from "@/lib/project-metadata.server";
+
+import { ProjectMetadataProvider } from "./project-metadata-provider";
+
+type ProjectMetadataBoundaryProps = {
+ children: ReactNode;
+ maxAgeMilliseconds?: number;
+};
+
+export const ProjectMetadataBoundary = async ({
+ children,
+ maxAgeMilliseconds,
+}: ProjectMetadataBoundaryProps) => {
+ const initialSnapshot = await getProjectMetadataSnapshot();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/docs/components/project-metadata-provider.tsx b/apps/docs/components/project-metadata-provider.tsx
new file mode 100644
index 00000000..118df3d2
--- /dev/null
+++ b/apps/docs/components/project-metadata-provider.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import type {
+ ProjectMetadata,
+ ProjectMetadataSnapshot,
+} from "@/lib/project-metadata";
+
+import type { ReactNode } from "react";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ deriveProjectMetadata,
+ fetchProjectMetadata,
+ isProjectMetadataFresh,
+ mergeProjectMetadataSnapshots,
+ parseProjectMetadataCache,
+ preferNewerProjectMetadata,
+} from "@/lib/project-metadata";
+
+const CACHE_KEY = "magic-modal:project-metadata:v1";
+const DEFAULT_MAX_AGE_MILLISECONDS = 6 * 60 * 60 * 1_000;
+const FETCH_TIMEOUT_MILLISECONDS = 8_000;
+const FAILED_REFRESH_COOLDOWN_MILLISECONDS = 5 * 60 * 1_000;
+
+export type ProjectMetadataStatus = "ready" | "refreshing" | "unavailable";
+
+type ProjectMetadataContextValue = {
+ metadata: ProjectMetadata;
+ revalidate: () => Promise;
+ status: ProjectMetadataStatus;
+};
+
+type ProjectMetadataProviderProps = {
+ children: ReactNode;
+ initialSnapshot: ProjectMetadataSnapshot;
+ maxAgeMilliseconds?: number;
+};
+
+const ProjectMetadataContext =
+ createContext(null);
+
+let activeRefresh: Promise | null = null;
+
+const timeoutSignal = () =>
+ typeof AbortSignal.timeout === "function"
+ ? AbortSignal.timeout(FETCH_TIMEOUT_MILLISECONDS)
+ : undefined;
+
+const refreshProjectMetadata = () => {
+ if (activeRefresh === null) {
+ activeRefresh = fetchProjectMetadata({
+ signal: timeoutSignal(),
+ }).finally(() => {
+ activeRefresh = null;
+ });
+ }
+ return activeRefresh;
+};
+
+const readCache = () => {
+ try {
+ return parseProjectMetadataCache(window.localStorage.getItem(CACHE_KEY));
+ } catch {
+ return null;
+ }
+};
+
+const writeCache = (snapshot: ProjectMetadataSnapshot) => {
+ if (snapshot.fetchedAt === null) return;
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify(snapshot));
+ } catch {
+ // The snapshot still works for this page when storage is unavailable.
+ }
+};
+
+const hasAnySource = (snapshot: ProjectMetadataSnapshot) =>
+ Object.values(snapshot.sources).some(Boolean);
+
+export const ProjectMetadataProvider = ({
+ children,
+ initialSnapshot,
+ maxAgeMilliseconds = DEFAULT_MAX_AGE_MILLISECONDS,
+}: ProjectMetadataProviderProps) => {
+ const [snapshot, setSnapshot] = useState(initialSnapshot);
+ const [status, setStatus] = useState(
+ hasAnySource(initialSnapshot) ? "ready" : "unavailable",
+ );
+ const lastRefreshAttempt = useRef(Number.NEGATIVE_INFINITY);
+ const snapshotRef = useRef(initialSnapshot);
+
+ const updateSnapshot = useCallback((next: ProjectMetadataSnapshot) => {
+ snapshotRef.current = next;
+ setSnapshot(next);
+ setStatus(hasAnySource(next) ? "ready" : "unavailable");
+ }, []);
+
+ const revalidate = useCallback(async () => {
+ lastRefreshAttempt.current = Date.now();
+ setStatus("refreshing");
+ try {
+ const refreshed = await refreshProjectMetadata();
+ const next = mergeProjectMetadataSnapshots(
+ snapshotRef.current,
+ refreshed,
+ );
+ updateSnapshot(next);
+ writeCache(next);
+ return next;
+ } catch {
+ const { current } = snapshotRef;
+ setStatus(hasAnySource(current) ? "ready" : "unavailable");
+ return current;
+ }
+ }, [updateSnapshot]);
+
+ useEffect(() => {
+ let cancelled = false;
+ queueMicrotask(() => {
+ if (cancelled) return;
+ const cached = readCache();
+ const preferred =
+ cached === null
+ ? initialSnapshot
+ : preferNewerProjectMetadata(initialSnapshot, cached);
+ updateSnapshot(preferred);
+ writeCache(preferred);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [initialSnapshot, updateSnapshot]);
+
+ useEffect(() => {
+ const now = Date.now();
+ const fetchedAt =
+ snapshot.fetchedAt === null
+ ? Number.NEGATIVE_INFINITY
+ : Date.parse(snapshot.fetchedAt);
+ const refreshDelay = isProjectMetadataFresh(
+ snapshot,
+ maxAgeMilliseconds,
+ now,
+ )
+ ? maxAgeMilliseconds - (now - fetchedAt)
+ : Math.max(
+ 0,
+ FAILED_REFRESH_COOLDOWN_MILLISECONDS -
+ (now - lastRefreshAttempt.current),
+ );
+ const refreshTimer = window.setTimeout(
+ () => {
+ void revalidate();
+ },
+ Math.min(Math.max(refreshDelay, 0), 2_147_483_647),
+ );
+ return () => window.clearTimeout(refreshTimer);
+ }, [maxAgeMilliseconds, revalidate, snapshot]);
+
+ useEffect(() => {
+ const receiveCache = (event: StorageEvent) => {
+ if (event.key !== CACHE_KEY) return;
+ const cached = parseProjectMetadataCache(event.newValue);
+ if (cached === null) return;
+ updateSnapshot(preferNewerProjectMetadata(snapshotRef.current, cached));
+ };
+ window.addEventListener("storage", receiveCache);
+ return () => window.removeEventListener("storage", receiveCache);
+ }, [updateSnapshot]);
+
+ const value = useMemo(
+ () => ({
+ metadata: deriveProjectMetadata(snapshot),
+ revalidate,
+ status,
+ }),
+ [revalidate, snapshot, status],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useProjectMetadata = () => {
+ const context = useContext(ProjectMetadataContext);
+ if (context === null) {
+ throw new Error(
+ "useProjectMetadata must be rendered inside ProjectMetadataProvider.",
+ );
+ }
+ return context;
+};
diff --git a/apps/docs/components/project-metadata-values.tsx b/apps/docs/components/project-metadata-values.tsx
new file mode 100644
index 00000000..23e23826
--- /dev/null
+++ b/apps/docs/components/project-metadata-values.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { formatProjectCount } from "@/lib/project-metadata";
+
+import { useProjectMetadata } from "./project-metadata-provider";
+
+type ProjectMetadataValueProps = {
+ fallback?: ReactNode;
+};
+
+const valueOrFallback = (value: ReactNode | null, fallback: ReactNode) => (
+ <>{value ?? fallback}>
+);
+
+export const ProjectStars = ({
+ fallback = null,
+}: ProjectMetadataValueProps) => {
+ const { metadata } = useProjectMetadata();
+ return valueOrFallback(formatProjectCount(metadata.stars), fallback);
+};
+
+export const ProjectWeeklyDownloads = ({
+ fallback = null,
+}: ProjectMetadataValueProps) => {
+ const { metadata } = useProjectMetadata();
+ return valueOrFallback(
+ formatProjectCount(metadata.downloadsLastWeek),
+ fallback,
+ );
+};
+
+export const ProjectVersion = ({
+ fallback = null,
+}: ProjectMetadataValueProps) => {
+ const { metadata } = useProjectMetadata();
+ return valueOrFallback(metadata.versionLabel, fallback);
+};
+
+export const ProjectLicense = ({
+ fallback = null,
+}: ProjectMetadataValueProps) => {
+ const { metadata } = useProjectMetadata();
+ return valueOrFallback(metadata.license, fallback);
+};
+
+export const ProjectAge = ({ fallback = null }: ProjectMetadataValueProps) => {
+ const { metadata } = useProjectMetadata();
+ const age =
+ metadata.createdYear === null ? null : `since ${metadata.createdYear}`;
+ return valueOrFallback(age, fallback);
+};
diff --git a/apps/docs/components/result-lab.tsx b/apps/docs/components/result-lab.tsx
new file mode 100644
index 00000000..2127e490
--- /dev/null
+++ b/apps/docs/components/result-lab.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import type { MouseEvent } from "react";
+
+import { useCallback, useState } from "react";
+
+type CloseReason =
+ | "BACKDROP_PRESS"
+ | "BACK_BUTTON_PRESS"
+ | "GLOBAL_HIDE_ALL"
+ | "INTENTIONAL_HIDE"
+ | "SWIPE_COMPLETE";
+
+const outcomes: {
+ reason: CloseReason;
+ short: string;
+ trigger: string;
+}[] = [
+ {
+ reason: "INTENTIONAL_HIDE",
+ short: "answer",
+ trigger: "hide({ answer: 42 })",
+ },
+ {
+ reason: "BACKDROP_PRESS",
+ short: "backdrop",
+ trigger: "tap the live backdrop",
+ },
+ {
+ reason: "SWIPE_COMPLETE",
+ short: "swipe",
+ trigger: "complete the swipe",
+ },
+ {
+ reason: "BACK_BUTTON_PRESS",
+ short: "Android back",
+ trigger: "simulate the Android system back action",
+ },
+ {
+ reason: "GLOBAL_HIDE_ALL",
+ short: "hideAll",
+ trigger: "magicModal.hideAll()",
+ },
+];
+
+const triggerByReason = Object.fromEntries(
+ outcomes.map(({ reason, trigger }) => [reason, trigger]),
+) as Record;
+
+export const ResultLab = () => {
+ const [reason, setReason] = useState(null);
+ const hasData = reason === "INTENTIONAL_HIDE";
+ const selectReason = useCallback((event: MouseEvent) => {
+ setReason(event.currentTarget.dataset.reason as CloseReason);
+ }, []);
+ const answer = useCallback(() => setReason("INTENTIONAL_HIDE"), []);
+ const closeFromBackdrop = useCallback(() => setReason("BACKDROP_PRESS"), []);
+ let receiptState = "promise pending";
+ if (reason !== null) {
+ receiptState = hasData ? "with data" : "reason only";
+ }
+
+ return (
+
+
+ Trigger any close path
+
+ {outcomes.map((outcome) => (
+
+
+ {outcome.short}
+
+ ))}
+
+
+
+
+
+ tap backdrop
+
+
+
+
Choose an answer
+
Answer from the sheet or dismiss it another way.
+
+ Answer 42
+
+
+
+
+
+
+
+
+ HideReturn<Answer>
+ {receiptState}
+
+ {reason === null ? (
+
+
+ {"const result = await handle.promise;\n// waiting for a close"}
+
+
+ ) : (
+
+
+ {"{"}
+ {"\n reason: "}
+ MagicModalHideReason.
+ {reason}
+ {hasData && (
+ <>
+ {",\n data: "}
+ {"{ answer: 42 }"}
+ >
+ )}
+ {"\n"}
+ {"}"}
+
+
+ )}
+
+ {reason === null
+ ? "Press an action to resolve the promise"
+ : triggerByReason[reason]}
+
+
+
+
+ );
+};
diff --git a/apps/docs/content/docs/getting-started/installation.mdx b/apps/docs/content/docs/getting-started/installation.mdx
index 7ed3d94d..195482a2 100644
--- a/apps/docs/content/docs/getting-started/installation.mdx
+++ b/apps/docs/content/docs/getting-started/installation.mdx
@@ -48,6 +48,56 @@ pnpm add react-native-magic-modal
No gesture-handler exclusion is needed. Magic Modal 9 supports both the 2.x builder API and the 3.x
hook API.
+## Next.js
+
+Install React Native Web:
+
+```bash
+pnpm add react-native-web
+```
+
+Next 16 uses Turbopack by default. Point React Native imports at React Native Web and resolve
+platform files first:
+
+```ts title="next.config.ts"
+import type { NextConfig } from "next";
+
+const config = {
+ transpilePackages: [
+ "react-native-magic-modal",
+ "react-native-gesture-handler",
+ "react-native-reanimated",
+ "react-native-screens",
+ "react-native-web",
+ "react-native-worklets",
+ ],
+ turbopack: {
+ resolveAlias: {
+ "react-native": "react-native-web",
+ },
+ resolveExtensions: [
+ ".web.tsx",
+ ".web.ts",
+ ".web.jsx",
+ ".web.js",
+ ".web.mjs",
+ ".web.cjs",
+ ".tsx",
+ ".ts",
+ ".jsx",
+ ".js",
+ ".mjs",
+ ".cjs",
+ ".json",
+ ],
+ },
+} satisfies NextConfig;
+
+export default config;
+```
+
+The interactive demo on this site uses this setup.
+
## Minimum versions
| Package | Version |
@@ -77,14 +127,14 @@ module.exports = function (api) {
return {
presets: ["module:@react-native/babel-preset"],
plugins: [
- // Other plugins…
+ // Existing plugins
"react-native-worklets/plugin",
],
};
};
```
-Then install iOS pods:
+Install iOS pods:
```bash
npx pod-install
@@ -94,7 +144,7 @@ Expo's default Babel setup already includes the required plugin. `react-native-s
iOS full-window overlay used by the portal; install it even if your navigator already brings it
transitively.
-
+
Version 8.0.0 temporarily required Gesture Handler 3. Upgrade Magic Modal to 9
or newer, and remove `react-native-gesture-handler` from
`expo.install.exclude` if you added it.
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index 86f4faa7..fd6a4e5f 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -4,8 +4,7 @@ description: Build imperative, awaitable, type-safe modal flows in React Native.
---
React Native Magic Modal turns a modal interaction into a value you can await. Mount one portal
-near the root of your app, then show a modal from a component, navigation handler, service, or
-plain function.
+near the app root. Call `show()` from anywhere.
```tsx
const result = await magicModal.show(() => (
@@ -25,12 +24,12 @@ if (
For a scroll-heavy bottom sheet with snap points, use a dedicated bottom-sheet
- library. Magic Modal is strongest when a modal represents a discrete
- interaction or async decision.
+ library. Use Magic Modal for discrete interactions and async decisions.
## Next step
-Install the package and its peers, then mount the portal in your app root.
+Install the package and its peers. Mount the portal in your app root.
diff --git a/apps/docs/lib/layout.tsx b/apps/docs/lib/layout.tsx
index 3a65a7e7..bd6ae014 100644
--- a/apps/docs/lib/layout.tsx
+++ b/apps/docs/lib/layout.tsx
@@ -4,6 +4,7 @@ import { BookOpenText } from "lucide-react";
import { createMagicDocsLayout } from "magic-docs/fumadocs";
import { MagicMark } from "@/components/magic-mark";
+import { ProjectVersion } from "@/components/project-metadata-values";
import { site } from "./site";
@@ -27,7 +28,9 @@ export const baseOptions = (): BaseLayoutProps => {
Magic Modal
- v9
+
+
+
),
url: "/",
diff --git a/apps/docs/lib/project-metadata.server.ts b/apps/docs/lib/project-metadata.server.ts
new file mode 100644
index 00000000..94275ecf
--- /dev/null
+++ b/apps/docs/lib/project-metadata.server.ts
@@ -0,0 +1,19 @@
+import "server-only";
+import { cache } from "react";
+
+import {
+ emptyProjectMetadataSnapshot,
+ fetchProjectMetadata,
+} from "./project-metadata";
+
+const FETCH_TIMEOUT_MILLISECONDS = 8_000;
+
+export const getProjectMetadataSnapshot = cache(async () => {
+ try {
+ return await fetchProjectMetadata({
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MILLISECONDS),
+ });
+ } catch {
+ return emptyProjectMetadataSnapshot();
+ }
+});
diff --git a/apps/docs/lib/project-metadata.ts b/apps/docs/lib/project-metadata.ts
new file mode 100644
index 00000000..99348d45
--- /dev/null
+++ b/apps/docs/lib/project-metadata.ts
@@ -0,0 +1,301 @@
+export const projectMetadataConfig = {
+ githubApi: "https://api.github.com/repos/GSTJ/react-native-magic-modal",
+ npmDownloadsApi:
+ "https://api.npmjs.org/downloads/point/last-week/react-native-magic-modal",
+ npmPackage: "react-native-magic-modal",
+ npmRegistryApi: "https://registry.npmjs.org/react-native-magic-modal/latest",
+ repository: "https://github.com/GSTJ/react-native-magic-modal",
+} as const;
+
+export type ProjectMetadataSource = "github" | "npmDownloads" | "npmRegistry";
+
+export type ProjectMetadataSnapshot = {
+ createdAt: string | null;
+ downloadsLastWeek: number | null;
+ fetchedAt: string | null;
+ latestVersion: string | null;
+ license: string | null;
+ sources: Record;
+ stars: number | null;
+};
+
+export type ProjectMetadata = ProjectMetadataSnapshot & {
+ ageYears: number | null;
+ createdYear: number | null;
+ releaseUrl: string | null;
+ versionLabel: string | null;
+};
+
+type FetchProjectMetadataOptions = {
+ fetcher?: typeof fetch;
+ githubToken?: string;
+ now?: () => Date;
+ signal?: AbortSignal;
+};
+
+type UnknownRecord = Record;
+
+const isRecord = (value: unknown): value is UnknownRecord =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isNullableString = (value: unknown): value is string | null =>
+ value === null || typeof value === "string";
+
+const isNullableCount = (value: unknown): value is number | null =>
+ value === null ||
+ (typeof value === "number" && Number.isSafeInteger(value) && value >= 0);
+
+const isValidDate = (value: string) => !Number.isNaN(Date.parse(value));
+
+const normalizeDate = (value: unknown): string | null => {
+ if (typeof value !== "string" || !isValidDate(value)) return null;
+ return new Date(value).toISOString();
+};
+
+const normalizeCount = (value: unknown): number | null => {
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
+ return null;
+ }
+ return value;
+};
+
+const normalizeLicense = (value: unknown): string | null => {
+ if (!isRecord(value)) return null;
+ const spdxID = value.spdx_id;
+ if (
+ typeof spdxID !== "string" ||
+ !/^[0-9A-Za-z.+-]{1,64}$/.test(spdxID) ||
+ spdxID === "NOASSERTION"
+ ) {
+ return null;
+ }
+ return spdxID;
+};
+
+const normalizeVersion = (value: unknown): string | null => {
+ if (typeof value !== "string") return null;
+ const version = value.trim().replace(/^v/, "");
+ if (
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)
+ ) {
+ return null;
+ }
+ return version;
+};
+
+const successfulSourceCount = (sources: ProjectMetadataSnapshot["sources"]) =>
+ Object.values(sources).filter(Boolean).length;
+
+const fetchJSON = async (
+ fetcher: typeof fetch,
+ url: string,
+ init: RequestInit,
+): Promise => {
+ try {
+ const response = await fetcher(url, init);
+ if (!response.ok) return null;
+ const payload: unknown = await response.json();
+ return isRecord(payload) ? payload : null;
+ } catch {
+ return null;
+ }
+};
+
+export const emptyProjectMetadataSnapshot = (): ProjectMetadataSnapshot => ({
+ createdAt: null,
+ downloadsLastWeek: null,
+ fetchedAt: null,
+ latestVersion: null,
+ license: null,
+ sources: {
+ github: false,
+ npmDownloads: false,
+ npmRegistry: false,
+ },
+ stars: null,
+});
+
+export const isProjectMetadataSnapshot = (
+ value: unknown,
+): value is ProjectMetadataSnapshot => {
+ if (!isRecord(value) || !isRecord(value.sources)) return false;
+
+ return (
+ isNullableString(value.createdAt) &&
+ (value.createdAt === null || isValidDate(value.createdAt)) &&
+ isNullableCount(value.downloadsLastWeek) &&
+ isNullableString(value.fetchedAt) &&
+ (value.fetchedAt === null || isValidDate(value.fetchedAt)) &&
+ isNullableString(value.latestVersion) &&
+ (value.latestVersion === null ||
+ normalizeVersion(value.latestVersion) === value.latestVersion) &&
+ isNullableString(value.license) &&
+ (value.license === null || /^[0-9A-Za-z.+-]{1,64}$/.test(value.license)) &&
+ typeof value.sources.github === "boolean" &&
+ typeof value.sources.npmDownloads === "boolean" &&
+ typeof value.sources.npmRegistry === "boolean" &&
+ isNullableCount(value.stars)
+ );
+};
+
+export const fetchProjectMetadata = async ({
+ fetcher = fetch,
+ githubToken,
+ now = () => new Date(),
+ signal,
+}: FetchProjectMetadataOptions = {}): Promise => {
+ const githubHeaders = new Headers({
+ Accept: "application/vnd.github+json",
+ });
+ if (githubToken) {
+ githubHeaders.set("Authorization", `Bearer ${githubToken}`);
+ }
+
+ const [github, npmDownloads, npmRegistry] = await Promise.all([
+ fetchJSON(fetcher, projectMetadataConfig.githubApi, {
+ cache: "no-store",
+ headers: githubHeaders,
+ signal,
+ }),
+ fetchJSON(fetcher, projectMetadataConfig.npmDownloadsApi, {
+ cache: "no-store",
+ signal,
+ }),
+ fetchJSON(fetcher, projectMetadataConfig.npmRegistryApi, {
+ cache: "no-store",
+ signal,
+ }),
+ ]);
+
+ const sources = {
+ github: github !== null,
+ npmDownloads: npmDownloads !== null,
+ npmRegistry: npmRegistry !== null,
+ };
+
+ return {
+ createdAt: normalizeDate(github?.created_at),
+ downloadsLastWeek: normalizeCount(npmDownloads?.downloads),
+ fetchedAt: successfulSourceCount(sources) > 0 ? now().toISOString() : null,
+ latestVersion: normalizeVersion(npmRegistry?.version),
+ license: normalizeLicense(github?.license),
+ sources,
+ stars: normalizeCount(github?.stargazers_count),
+ };
+};
+
+export const mergeProjectMetadataSnapshots = (
+ current: ProjectMetadataSnapshot,
+ refreshed: ProjectMetadataSnapshot,
+): ProjectMetadataSnapshot => {
+ const { github, npmDownloads, npmRegistry } = refreshed.sources;
+ const hasRefresh = successfulSourceCount(refreshed.sources) > 0;
+
+ return {
+ createdAt: github ? refreshed.createdAt : current.createdAt,
+ downloadsLastWeek: npmDownloads
+ ? refreshed.downloadsLastWeek
+ : current.downloadsLastWeek,
+ fetchedAt: hasRefresh ? refreshed.fetchedAt : current.fetchedAt,
+ latestVersion: npmRegistry
+ ? refreshed.latestVersion
+ : current.latestVersion,
+ license: github ? refreshed.license : current.license,
+ sources: {
+ github: github || current.sources.github,
+ npmDownloads: npmDownloads || current.sources.npmDownloads,
+ npmRegistry: npmRegistry || current.sources.npmRegistry,
+ },
+ stars: github ? refreshed.stars : current.stars,
+ };
+};
+
+const timestamp = (value: string | null) =>
+ value === null ? Number.NEGATIVE_INFINITY : Date.parse(value);
+
+export const preferNewerProjectMetadata = (
+ first: ProjectMetadataSnapshot,
+ second: ProjectMetadataSnapshot,
+) =>
+ timestamp(second.fetchedAt) > timestamp(first.fetchedAt) ? second : first;
+
+export const isProjectMetadataFresh = (
+ snapshot: ProjectMetadataSnapshot,
+ maxAgeMilliseconds: number,
+ now = Date.now(),
+) => {
+ const fetchedAt = timestamp(snapshot.fetchedAt);
+ return (
+ Number.isFinite(fetchedAt) &&
+ maxAgeMilliseconds >= 0 &&
+ now - fetchedAt >= -60_000 &&
+ now - fetchedAt < maxAgeMilliseconds
+ );
+};
+
+export const projectAgeInYears = (
+ createdAt: string | null,
+ now = new Date(),
+): number | null => {
+ if (createdAt === null || !isValidDate(createdAt)) return null;
+
+ const created = new Date(createdAt);
+ let years = now.getUTCFullYear() - created.getUTCFullYear();
+ const beforeAnniversary =
+ now.getUTCMonth() < created.getUTCMonth() ||
+ (now.getUTCMonth() === created.getUTCMonth() &&
+ now.getUTCDate() < created.getUTCDate());
+
+ if (beforeAnniversary) years -= 1;
+ return Math.max(years, 0);
+};
+
+export const releaseUrlForVersion = (version: string | null) => {
+ const normalizedVersion = normalizeVersion(version);
+ return normalizedVersion === null
+ ? null
+ : `${projectMetadataConfig.repository}/releases/tag/magic-modal-${encodeURIComponent(normalizedVersion)}`;
+};
+
+export const deriveProjectMetadata = (
+ snapshot: ProjectMetadataSnapshot,
+ now = new Date(),
+): ProjectMetadata => {
+ const created =
+ snapshot.createdAt === null ? null : new Date(snapshot.createdAt);
+ const createdYear =
+ created === null || Number.isNaN(created.valueOf())
+ ? null
+ : created.getUTCFullYear();
+
+ return {
+ ...snapshot,
+ ageYears: projectAgeInYears(snapshot.createdAt, now),
+ createdYear,
+ releaseUrl: releaseUrlForVersion(snapshot.latestVersion),
+ versionLabel:
+ snapshot.latestVersion === null ? null : `v${snapshot.latestVersion}`,
+ };
+};
+
+export const formatProjectCount = (value: number | null) =>
+ value === null ? null : new Intl.NumberFormat("en-US").format(value);
+
+export const parseProjectMetadataCache = (
+ value: string | null,
+): ProjectMetadataSnapshot | null => {
+ if (value === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(value);
+ if (!isProjectMetadataSnapshot(parsed)) return null;
+ if (
+ parsed.fetchedAt !== null &&
+ Date.parse(parsed.fetchedAt) > Date.now() + 5 * 60 * 1_000
+ ) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+};
diff --git a/apps/docs/lib/site.ts b/apps/docs/lib/site.ts
index 7481cf3e..de9c32d8 100644
--- a/apps/docs/lib/site.ts
+++ b/apps/docs/lib/site.ts
@@ -7,7 +7,7 @@ import {
const presetSite = defineMagicDocs({
name: "React Native Magic Modal",
description:
- "Imperative, type-safe modal flows for React Native. Show a modal from anywhere and await the result.",
+ "Open a React Native modal from any async flow, await its typed result, and keep concurrent prompts in one ordered stack.",
repository: "https://github.com/GSTJ/react-native-magic-modal",
siteUrl: "https://gstj.github.io/react-native-magic-modal",
packageName: "react-native-magic-modal",
diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts
index ca0d6c33..25a5b842 100644
--- a/apps/docs/next.config.ts
+++ b/apps/docs/next.config.ts
@@ -8,6 +8,34 @@ import { site } from "./lib/site";
const config = {
...createMagicDocsStaticExport(site),
reactStrictMode: true,
+ transpilePackages: [
+ "react-native-magic-modal",
+ "react-native-gesture-handler",
+ "react-native-reanimated",
+ "react-native-screens",
+ "react-native-web",
+ "react-native-worklets",
+ ],
+ turbopack: {
+ resolveAlias: {
+ "react-native": "react-native-web",
+ },
+ resolveExtensions: [
+ ".web.tsx",
+ ".web.ts",
+ ".web.jsx",
+ ".web.js",
+ ".web.mjs",
+ ".web.cjs",
+ ".tsx",
+ ".ts",
+ ".jsx",
+ ".js",
+ ".mjs",
+ ".cjs",
+ ".json",
+ ],
+ },
} satisfies NextConfig;
export default createMDX()(config);
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 606c20b2..94c1516a 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -2,6 +2,7 @@
"name": "@magic-modal/docs",
"version": "0.0.0",
"private": true,
+ "type": "module",
"scripts": {
"build": "next build && node scripts/write-legacy-redirects.mjs && node scripts/check-artifact.mjs",
"dev": "next dev",
@@ -13,16 +14,28 @@
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit"
},
"dependencies": {
+ "@fontsource-variable/instrument-sans": "5.3.0",
+ "@fontsource-variable/jetbrains-mono": "5.3.0",
+ "@fontsource/instrument-serif": "5.3.0",
"@orama/orama": "^3.1.18",
"fumadocs-core": "16.12.1",
"fumadocs-mdx": "15.2.0",
"fumadocs-typescript": "5.3.0",
"fumadocs-ui": "npm:@fumadocs/base-ui@16.12.1",
+ "gsap": "3.15.0",
"lucide-react": "^1.25.0",
"magic-docs": "1.0.0",
"next": "16.2.12",
"react": "19.2.0",
- "react-dom": "19.2.0"
+ "react-dom": "19.2.0",
+ "react-native": "0.83.10",
+ "react-native-gesture-handler": "3.1.0",
+ "react-native-magic-modal": "workspace:*",
+ "react-native-reanimated": "4.2.3",
+ "react-native-screens": "4.23.0",
+ "react-native-web": "0.21.2",
+ "react-native-worklets": "0.7.4",
+ "server-only": "0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
diff --git a/apps/docs/public/og.svg b/apps/docs/public/og.svg
index 0e9b652d..8fe0ad5b 100644
--- a/apps/docs/public/og.svg
+++ b/apps/docs/public/og.svg
@@ -1,37 +1,71 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Magic Modal
+
+
+
+
+
+
+
+
+
+
+
+ Magic Modal
- Modal flows,
- without the ceremony.
- Imperative, awaitable, type-safe modals for React Native.
-
-
- $
- pnpm add react-native-magic-modal
+
+
+ AWAITABLE MODAL FLOWS FOR REACT NATIVE
+ React Native modals
+ you can await
+ One portal keeps concurrent prompts in an ordered stack.
+
+
+
+ $
+ pnpm add react-native-magic-modal
+
+
+
+
+
+
+
+
+
+
+
+
+ MAGICMODALPORTAL
+ promise pending
+ STACK 1
+
+
+
+
+
+
+
+
+
+ Rate the app
+ The caller awaits this answer.
+
+
+
+
+
+
+
+
+ 1
+ 2
+ 3
+ 4
+ 5
+
+
+
+ Return score 5
+
diff --git a/apps/docs/scripts/check-artifact.mjs b/apps/docs/scripts/check-artifact.mjs
index 863e2705..1806e14a 100644
--- a/apps/docs/scripts/check-artifact.mjs
+++ b/apps/docs/scripts/check-artifact.mjs
@@ -130,6 +130,9 @@ if (!home.includes(`${deploymentBasePath}/_next/`)) {
"Home page does not reference base-path-prefixed Next assets.",
);
}
+if (!home.includes('data-home-version="3"')) {
+ throw new Error("Home page is missing the origin-story landing marker.");
+}
const generatedReference = await readFile(
join(outputDirectory, "docs/reference/modal-props.md"),
diff --git a/apps/docs/test/project-metadata.test.mjs b/apps/docs/test/project-metadata.test.mjs
new file mode 100644
index 00000000..a107af40
--- /dev/null
+++ b/apps/docs/test/project-metadata.test.mjs
@@ -0,0 +1,181 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ deriveProjectMetadata,
+ emptyProjectMetadataSnapshot,
+ fetchProjectMetadata,
+ isProjectMetadataFresh,
+ mergeProjectMetadataSnapshots,
+ parseProjectMetadataCache,
+ projectAgeInYears,
+} from "../lib/project-metadata.ts";
+
+const jsonResponse = (payload, status = 200) =>
+ new Response(JSON.stringify(payload), {
+ headers: { "content-type": "application/json" },
+ status,
+ });
+
+test("collects project metadata without rounding or fallback metrics", async () => {
+ const fetcher = (url) => {
+ if (url.includes("api.github.com")) {
+ return jsonResponse({
+ created_at: "2022-02-21T10:00:00Z",
+ license: { spdx_id: "MIT" },
+ stargazers_count: 641,
+ });
+ }
+ if (url.includes("downloads/point")) {
+ return jsonResponse({ downloads: 3950 });
+ }
+ return jsonResponse({ version: "9.0.1" });
+ };
+
+ const snapshot = await fetchProjectMetadata({
+ fetcher,
+ now: () => new Date("2026-07-28T12:00:00Z"),
+ });
+ const metadata = deriveProjectMetadata(
+ snapshot,
+ new Date("2026-07-28T12:00:00Z"),
+ );
+
+ assert.equal(metadata.stars, 641);
+ assert.equal(metadata.downloadsLastWeek, 3950);
+ assert.equal(metadata.license, "MIT");
+ assert.equal(metadata.createdYear, 2022);
+ assert.equal(metadata.ageYears, 4);
+ assert.equal(metadata.versionLabel, "v9.0.1");
+ assert.equal(
+ metadata.releaseUrl,
+ "https://github.com/GSTJ/react-native-magic-modal/releases/tag/magic-modal-9.0.1",
+ );
+});
+
+test("keeps known values when one refresh source is unavailable", () => {
+ const current = {
+ createdAt: "2022-02-21T10:00:00.000Z",
+ downloadsLastWeek: 3950,
+ fetchedAt: "2026-07-28T10:00:00.000Z",
+ latestVersion: "9.0.1",
+ license: "MIT",
+ sources: {
+ github: true,
+ npmDownloads: true,
+ npmRegistry: true,
+ },
+ stars: 641,
+ };
+ const refreshed = {
+ ...emptyProjectMetadataSnapshot(),
+ downloadsLastWeek: 4012,
+ fetchedAt: "2026-07-28T12:00:00.000Z",
+ sources: {
+ github: false,
+ npmDownloads: true,
+ npmRegistry: false,
+ },
+ };
+
+ assert.deepEqual(mergeProjectMetadataSnapshots(current, refreshed), {
+ ...current,
+ downloadsLastWeek: 4012,
+ fetchedAt: refreshed.fetchedAt,
+ });
+});
+
+test("uses null when every source fails", async () => {
+ const snapshot = await fetchProjectMetadata({
+ fetcher: () => jsonResponse({ message: "unavailable" }, 503),
+ });
+
+ assert.deepEqual(snapshot, emptyProjectMetadataSnapshot());
+});
+
+test("rejects invalid metrics from successful responses", async () => {
+ const snapshot = await fetchProjectMetadata({
+ fetcher: (url) => {
+ if (url.includes("api.github.com")) {
+ return jsonResponse({
+ created_at: "sometime",
+ license: { spdx_id: "NOASSERTION" },
+ stargazers_count: -1,
+ });
+ }
+ if (url.includes("downloads/point")) {
+ return jsonResponse({ downloads: "many" });
+ }
+ return jsonResponse({ version: "latest" });
+ },
+ now: () => new Date("2026-07-28T12:00:00Z"),
+ });
+
+ assert.deepEqual(snapshot, {
+ ...emptyProjectMetadataSnapshot(),
+ fetchedAt: "2026-07-28T12:00:00.000Z",
+ sources: {
+ github: true,
+ npmDownloads: true,
+ npmRegistry: true,
+ },
+ });
+});
+
+test("validates cache payloads before using them", () => {
+ const snapshot = {
+ ...emptyProjectMetadataSnapshot(),
+ fetchedAt: "2026-07-28T12:00:00.000Z",
+ latestVersion: "9.0.1",
+ sources: {
+ github: false,
+ npmDownloads: false,
+ npmRegistry: true,
+ },
+ };
+
+ assert.deepEqual(
+ parseProjectMetadataCache(JSON.stringify(snapshot)),
+ snapshot,
+ );
+ assert.equal(parseProjectMetadataCache('{"stars":"many"}'), null);
+ assert.equal(parseProjectMetadataCache("not json"), null);
+});
+
+test("computes completed years and cache freshness", () => {
+ assert.equal(
+ projectAgeInYears(
+ "2022-07-29T00:00:00.000Z",
+ new Date("2026-07-28T23:59:59.000Z"),
+ ),
+ 3,
+ );
+ assert.equal(
+ projectAgeInYears(
+ "2022-07-28T00:00:00.000Z",
+ new Date("2026-07-28T00:00:00.000Z"),
+ ),
+ 4,
+ );
+
+ const snapshot = {
+ ...emptyProjectMetadataSnapshot(),
+ fetchedAt: "2026-07-28T10:00:00.000Z",
+ };
+ assert.equal(
+ isProjectMetadataFresh(
+ snapshot,
+ 60 * 60 * 1_000,
+ Date.parse("2026-07-28T10:59:59.000Z"),
+ ),
+ true,
+ );
+ assert.equal(
+ isProjectMetadataFresh(
+ snapshot,
+ 60 * 60 * 1_000,
+ Date.parse("2026-07-28T11:00:00.000Z"),
+ ),
+ false,
+ );
+});
diff --git a/examples/kitchen-sink/tsconfig.json b/examples/kitchen-sink/tsconfig.json
index af6ba481..0e8ec53d 100644
--- a/examples/kitchen-sink/tsconfig.json
+++ b/examples/kitchen-sink/tsconfig.json
@@ -11,5 +11,5 @@
}
},
"include": ["."],
- "exclude": ["node_modules", "android", "ios"]
+ "exclude": ["node_modules", "android", "ios", "dist"]
}
diff --git a/media/magic-modal-demo-poster.png b/media/magic-modal-demo-poster.png
new file mode 100644
index 00000000..7620d3f3
Binary files /dev/null and b/media/magic-modal-demo-poster.png differ
diff --git a/media/magic-modal-demo.gif b/media/magic-modal-demo.gif
new file mode 100644
index 00000000..bd0d7364
Binary files /dev/null and b/media/magic-modal-demo.gif differ
diff --git a/media/magic-modal-demo.mp4 b/media/magic-modal-demo.mp4
new file mode 100644
index 00000000..9793c01a
Binary files /dev/null and b/media/magic-modal-demo.mp4 differ
diff --git a/package.json b/package.json
index 107d6e79..fe79f533 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,8 @@
"changelog:check": "turbo run changelog:check --filter=react-native-magic-modal",
"build": "turbo run build",
"release": "turbo run release --filter=react-native-magic-modal",
- "docs": "pnpm --filter @magic-modal/docs build",
- "docs:dev": "pnpm --filter @magic-modal/docs dev",
+ "docs": "pnpm --filter react-native-magic-modal build && pnpm --filter @magic-modal/docs build",
+ "docs:dev": "pnpm --filter react-native-magic-modal build && pnpm --filter @magic-modal/docs dev",
"doctor": "turbo run doctor"
},
"config": {
diff --git a/packages/modal/babel.config.js b/packages/modal/babel.config.js
index c5516061..82e3807c 100644
--- a/packages/modal/babel.config.js
+++ b/packages/modal/babel.config.js
@@ -3,6 +3,6 @@ export default (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
- plugins: ["react-native-reanimated/plugin"],
+ plugins: ["react-native-worklets/plugin"],
};
};
diff --git a/packages/modal/package.json b/packages/modal/package.json
index 9988144b..13d0053c 100644
--- a/packages/modal/package.json
+++ b/packages/modal/package.json
@@ -4,9 +4,13 @@
"description": "A magic modal that can be used imperatively from anywhere! 🦄",
"keywords": [
"android",
+ "expo",
"ios",
"modal",
- "react-native"
+ "nextjs",
+ "react-native",
+ "react-native-web",
+ "web"
],
"homepage": "https://gstj.github.io/react-native-magic-modal/",
"bugs": {
@@ -43,7 +47,7 @@
"registry": "https://registry.npmjs.org/"
},
"scripts": {
- "build": "bunchee",
+ "build": "bunchee && node tools/wrap-entrypoints.mjs && node tools/check-web-build.mjs",
"lint": "oxlint --report-unused-disable-directives",
"lint:fix": "oxlint --report-unused-disable-directives --fix",
"format": "oxfmt --check .",
diff --git a/packages/modal/src/components/MagicModalPortal/magic-modal-portal.tsx b/packages/modal/src/components/MagicModalPortal/magic-modal-portal.tsx
index 72d45810..7a503ee8 100644
--- a/packages/modal/src/components/MagicModalPortal/magic-modal-portal.tsx
+++ b/packages/modal/src/components/MagicModalPortal/magic-modal-portal.tsx
@@ -15,8 +15,8 @@ import React, {
} from "react";
import { BackHandler, Platform, StyleSheet, View } from "react-native";
-/** Do not import FullWindowOverlay from react-native-screens directly, as it screws up code splitting */
-import FullWindowOverlay from "react-native-screens/src/components/FullWindowOverlay";
+// eslint-disable-next-line import/no-namespace -- A named import is rewritten to a private screens path in the ESM build.
+import * as ReactNativeScreens from "react-native-screens";
import { defaultConfig } from "../../constants/default-config";
import { MagicModalHideReason } from "../../constants/types";
@@ -163,6 +163,10 @@ export const MagicModalPortal: React.FC = memo(() => {
);
useEffect(() => {
+ if (Platform.OS === "web") {
+ return;
+ }
+
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
() => {
@@ -232,7 +236,7 @@ export const MagicModalPortal: React.FC = memo(() => {
const Overlay =
fullWindowOverlayEnabled && Platform.OS === "ios"
- ? FullWindowOverlay
+ ? ReactNativeScreens.FullWindowOverlay
: React.Fragment;
/* This needs to always be rendered, if we make it conditionally render based on ModalContent too,
diff --git a/packages/modal/src/components/MagicModalPortal/web-imports.test.ts b/packages/modal/src/components/MagicModalPortal/web-imports.test.ts
new file mode 100644
index 00000000..fbbcc969
--- /dev/null
+++ b/packages/modal/src/components/MagicModalPortal/web-imports.test.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+describe("web-compatible package imports", () => {
+ it("loads FullWindowOverlay through the public react-native-screens entry", () => {
+ const portalSource = readFileSync(
+ join(
+ process.cwd(),
+ "src/components/MagicModalPortal/magic-modal-portal.tsx",
+ ),
+ "utf8",
+ );
+
+ expect(portalSource).toContain(
+ 'import * as ReactNativeScreens from "react-native-screens";',
+ );
+ expect(portalSource).not.toContain("react-native-screens/src/");
+ });
+
+ it("declares web dependencies for both animated styles", () => {
+ const modalSource = readFileSync(
+ join(process.cwd(), "src/components/magic-modal.tsx"),
+ "utf8",
+ );
+
+ expect(modalSource).toContain("}, [translationX, translationY]);");
+ expect(modalSource).toContain(
+ `}, [
+ config.swipeDirection,
+ isHorizontal,
+ rangeMap,
+ translationX,
+ translationY,
+ ]);`,
+ );
+ });
+
+ it("does not leave Reanimated exit clones behind on web", () => {
+ const modalSource = readFileSync(
+ join(process.cwd(), "src/components/magic-modal.tsx"),
+ "utf8",
+ );
+
+ expect(modalSource).toContain(
+ 'Platform.OS === "web" ? undefined : FadeOut',
+ );
+ expect(modalSource).toContain('isSwipeComplete || Platform.OS === "web"');
+ });
+});
diff --git a/packages/modal/src/components/magic-modal.tsx b/packages/modal/src/components/magic-modal.tsx
index cb216b5e..1fb4e240 100644
--- a/packages/modal/src/components/magic-modal.tsx
+++ b/packages/modal/src/components/magic-modal.tsx
@@ -10,7 +10,13 @@ import type { Direction, ModalChildren, ModalProps } from "../constants/types";
import type { SwipeGestureSpec } from "./panGesture";
import React, { memo, useMemo } from "react";
-import { Pressable, StyleSheet, useWindowDimensions, View } from "react-native";
+import {
+ Platform,
+ Pressable,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from "react-native";
import Animated, {
Extrapolation,
@@ -236,7 +242,7 @@ export const MagicModal = memo(
{ translateY: translationY.value },
],
};
- });
+ }, [translationX, translationY]);
const animatedBackdropStyles = useAnimatedStyle(() => {
"worklet";
@@ -252,16 +258,23 @@ export const MagicModal = memo(
Extrapolation.CLAMP,
),
};
- });
+ }, [
+ config.swipeDirection,
+ isHorizontal,
+ rangeMap,
+ translationX,
+ translationY,
+ ]);
const isBackdropVisible = !config.hideBackdrop;
+ const webExitingAnimation = Platform.OS === "web" ? undefined : FadeOut;
return (
({
+ output: await readFile(new URL(`../${outputFile}`, import.meta.url), {
+ encoding: "utf8",
+ }),
+ outputFile,
+ })),
+);
+
+for (const { output, outputFile } of outputs) {
+ if (output.includes(privateScreensImport)) {
+ throw new Error(
+ `${outputFile} reaches into a private react-native-screens module.`,
+ );
+ }
+}
+
+const entrypointOutputs = await Promise.all(
+ entrypoints.map(async (entrypoint) => ({
+ ...entrypoint,
+ output: await readFile(
+ new URL(`../${entrypoint.entrypoint}`, import.meta.url),
+ {
+ encoding: "utf8",
+ },
+ ),
+ })),
+);
+
+for (const {
+ entrypoint,
+ implementation,
+ output,
+ runtimeGuard,
+} of entrypointOutputs) {
+ const guardPosition = output.indexOf(runtimeGuard);
+ const implementationPosition = output.indexOf(implementation);
+
+ if (guardPosition === -1 || implementationPosition === -1) {
+ throw new Error(`${entrypoint} is missing its web runtime boundary.`);
+ }
+
+ if (guardPosition > implementationPosition) {
+ throw new Error(`${entrypoint} loads its implementation before __DEV__.`);
+ }
+}
+
+console.log(
+ "✓ Web package boundary: runtime guard loads first and screens stays public",
+);
diff --git a/packages/modal/tools/wrap-entrypoints.mjs b/packages/modal/tools/wrap-entrypoints.mjs
new file mode 100644
index 00000000..f2d7977c
--- /dev/null
+++ b/packages/modal/tools/wrap-entrypoints.mjs
@@ -0,0 +1,43 @@
+import { rename, writeFile } from "node:fs/promises";
+
+const distDirectory = new URL("../dist/", import.meta.url);
+
+const runtimeGuard = `const runtime = globalThis;
+
+if (typeof runtime.__DEV__ !== "boolean") {
+ runtime.__DEV__ =
+ typeof process !== "undefined"
+ ? process.env.NODE_ENV !== "production"
+ : false;
+}
+`;
+
+await Promise.all([
+ rename(
+ new URL("index.js", distDirectory),
+ new URL("index.runtime.js", distDirectory),
+ ),
+ rename(
+ new URL("index.cjs", distDirectory),
+ new URL("index.runtime.cjs", distDirectory),
+ ),
+]);
+
+await Promise.all([
+ writeFile(new URL("dev-runtime.js", distDirectory), runtimeGuard),
+ writeFile(new URL("dev-runtime.cjs", distDirectory), runtimeGuard),
+ writeFile(
+ new URL("index.js", distDirectory),
+ `import "./dev-runtime.js";
+
+export * from "./index.runtime.js";
+`,
+ ),
+ writeFile(
+ new URL("index.cjs", distDirectory),
+ `require("./dev-runtime.cjs");
+
+module.exports = require("./index.runtime.cjs");
+`,
+ ),
+]);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3701157b..7854fd49 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,6 +33,15 @@ importers:
apps/docs:
dependencies:
+ '@fontsource-variable/instrument-sans':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource-variable/jetbrains-mono':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource/instrument-serif':
+ specifier: 5.3.0
+ version: 5.3.0
'@orama/orama':
specifier: ^3.1.18
version: 3.1.18
@@ -48,6 +57,9 @@ importers:
fumadocs-ui:
specifier: npm:@fumadocs/base-ui@16.12.1
version: '@fumadocs/base-ui@16.12.1(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1(supports-color@8.1.1))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.27.0(react@19.2.0))(next@16.2.12(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(supports-color@8.1.1)(zod@4.4.3))(next@16.2.12(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@26.1.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.3.3)'
+ gsap:
+ specifier: 3.15.0
+ version: 3.15.0
lucide-react:
specifier: ^1.25.0
version: 1.27.0(react@19.2.0)
@@ -63,6 +75,30 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
+ react-native:
+ specifier: 0.83.10
+ version: 0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1)
+ react-native-gesture-handler:
+ specifier: 3.1.0
+ version: 3.1.0(react-native@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
+ react-native-magic-modal:
+ specifier: workspace:*
+ version: link:../../packages/modal
+ react-native-reanimated:
+ specifier: 4.2.3
+ version: 4.2.3(react-native-worklets@0.7.4(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)(supports-color@8.1.1))(react-native@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
+ react-native-screens:
+ specifier: 4.23.0
+ version: 4.23.0(react-native@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
+ react-native-web:
+ specifier: 0.21.2
+ version: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native-worklets:
+ specifier: 0.7.4
+ version: 0.7.4(@babel/core@7.29.7(supports-color@8.1.1))(react-native@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)(supports-color@8.1.1)
+ server-only:
+ specifier: 0.0.1
+ version: 0.0.1
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.3.3
@@ -1300,6 +1336,15 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
+ '@fontsource-variable/instrument-sans@5.3.0':
+ resolution: {integrity: sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw==}
+
+ '@fontsource-variable/jetbrains-mono@5.3.0':
+ resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==}
+
+ '@fontsource/instrument-serif@5.3.0':
+ resolution: {integrity: sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==}
+
'@fuma-translate/react@1.0.2':
resolution: {integrity: sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw==}
peerDependencies:
@@ -4942,6 +4987,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ gsap@3.15.0:
+ resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
+
handlebars@4.7.9:
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
engines: {node: '>=0.4.7'}
@@ -9105,6 +9153,12 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
+ '@fontsource-variable/instrument-sans@5.3.0': {}
+
+ '@fontsource-variable/jetbrains-mono@5.3.0': {}
+
+ '@fontsource/instrument-serif@5.3.0': {}
+
'@fuma-translate/react@1.0.2(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
react: 19.2.0
@@ -12730,6 +12784,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ gsap@3.15.0: {}
+
handlebars@4.7.9:
dependencies:
minimist: 1.2.8