diff --git a/app/app.css b/app/app.css
index 11fb9cd..a543de3 100644
--- a/app/app.css
+++ b/app/app.css
@@ -139,3 +139,44 @@
cursor: pointer;
}
}
+
+/*
+ * Modal — replace-style inner navigation (ModalViews). The active ModalView
+ * animates in place on push/pop; `data-direction` flips the offset so forward
+ * and back read differently. Shipped to consumers via the modal registry item's
+ * `css` field (scripts/build-registry.ts); kept here so the showcase matches.
+ */
+@keyframes modal-view-forward {
+ from {
+ opacity: 0;
+ transform: translateY(0.5rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes modal-view-back {
+ from {
+ opacity: 0;
+ transform: translateY(-0.375rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+[data-slot="modal-view"] {
+ animation: modal-view-forward 200ms ease-out;
+}
+[data-slot="modal-view"][data-direction="back"] {
+ animation-name: modal-view-back;
+}
+[data-slot="modal-view"][data-direction="none"] {
+ animation: none;
+}
+@media (prefers-reduced-motion: reduce) {
+ [data-slot="modal-view"] {
+ animation: none;
+ }
+}
diff --git a/app/showcase/components/demos.tsx b/app/showcase/components/demos.tsx
index a567204..1af06ef 100644
--- a/app/showcase/components/demos.tsx
+++ b/app/showcase/components/demos.tsx
@@ -1,4 +1,5 @@
import { ChoiceboxExamples, ChoiceboxPreview } from "./choicebox-demo";
+import { ModalExamples, ModalPreview } from "./modal-demo";
// The gallery's source of truth: one entry per published crescent-ui component.
// `preview` is the hero shown in the preview frame; `examples` are the extra
@@ -22,6 +23,14 @@ export const demos: Record<
preview: ,
examples: ,
},
+ modal: {
+ title: "Modal",
+ description:
+ "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel, replace-style inner navigation, and a pinned footer.",
+ sourceFile: "ui/modal.tsx",
+ preview: ,
+ examples: ,
+ },
};
export const demoOrder = Object.keys(demos);
diff --git a/app/showcase/components/modal-demo.tsx b/app/showcase/components/modal-demo.tsx
new file mode 100644
index 0000000..b9af196
--- /dev/null
+++ b/app/showcase/components/modal-demo.tsx
@@ -0,0 +1,196 @@
+import {
+ Modal,
+ ModalAside,
+ ModalBody,
+ ModalCarousel,
+ ModalCarouselDots,
+ ModalCarouselNav,
+ ModalCarouselViewport,
+ ModalClose,
+ ModalColumn,
+ ModalColumns,
+ ModalContent,
+ ModalDescription,
+ ModalFooter,
+ ModalHeader,
+ ModalSlide,
+ ModalTitle,
+ ModalTrigger,
+ ModalView,
+ ModalViews,
+ ModalViewsBack,
+ useModalViews,
+} from "@registry/bases/base/ui/modal";
+import { Button } from "~/ui/button";
+
+import { Example } from "./example";
+
+const PARAGRAPHS = Array.from(
+ { length: 8 },
+ (_, i) =>
+ `Paragraph ${i + 1}. The body is the single scroll region between the pinned header and footer — long content scrolls here while the chrome stays put.`,
+);
+
+const SLIDES = [
+ {
+ title: "Welcome",
+ body: "The carousel steps through ordered slides — drag is off, so progress is deliberate.",
+ },
+ {
+ title: "One step at a time",
+ body: "The footer's nav drives the track; the dots track which slide is active.",
+ },
+ {
+ title: "All set",
+ body: "On the last slide Next becomes Finish — wire onFinish to close or advance.",
+ },
+];
+
+// The hero — a framed dialog: pinned header + footer, the body owns the scroll.
+export function ModalPreview() {
+ return (
+
+ Open dialog} />
+
+
+ Framed dialog
+
+ Header and footer pin; the body owns the only scroll.
+
+
+
+ {PARAGRAPHS.map((p) => (
+
+ {p}
+
+ ))}
+
+
+ Cancel} />
+ Save} />
+
+
+
+ );
+}
+
+export function ModalExamples() {
+ return (
+
+
+
+ Open} />
+
+
+ Choose an option
+
+
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ Primary column item {i + 1}. This column scrolls
+ independently of the aside.
+
+ ))}
+
+
+
+ Summary
+
+
+ The trailing column is a distinguished muted panel — for a
+ value-prop, a preview, or a running summary.
+
+
+
+
+ Continue} />
+
+
+
+
+
+
+
+ Open tour} />
+
+
+
+ {SLIDES.map((slide) => (
+
+
+
+ {slide.title}
+
+
+ {slide.body}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Open} />
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+ Profile settings. The previous view is replaced in place;
+ Back pops the stack.
+
+
+
+
+ Billing settings. Each destination is its own view, mounted
+ only when active.
+
+
+
+
+
+
+
+
+ );
+}
+
+function ViewsHome() {
+ const { push } = useModalViews();
+ return (
+
+
+
+
+ );
+}
diff --git a/app/ui/carousel.tsx b/app/ui/carousel.tsx
new file mode 100644
index 0000000..6a3fec5
--- /dev/null
+++ b/app/ui/carousel.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
+
+import { cn } from "~/lib/utils";
+import { Button } from "~/ui/button";
+
+// Carousel — the stock shadcn embla carousel primitive. It is the raw slidable
+// track the higher-level `ModalCarousel` family (registry/bases/*/ui/modal)
+// composes on top of; a consumer's `shadcn add modal` pulls this in as the
+// `carousel` registry dependency. This app-side copy is what the showcase renders
+// the Modal carousel against.
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext]
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+ useCarousel,
+};
diff --git a/app/ui/dialog.tsx b/app/ui/dialog.tsx
new file mode 100644
index 0000000..4ba1f2b
--- /dev/null
+++ b/app/ui/dialog.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import * as React from "react";
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "~/lib/utils";
+import { Button } from "~/ui/button";
+
+// Dialog — the Base UI Dialog primitive dressed in crescent chrome. It is the
+// raw popup the higher-level `Modal` family (registry/bases/*/ui/modal) composes
+// on top of; on its own it's the standard centered dialog box.
+//
+// Style-tunable shape lives in the `cn-dialog-content` token (radius, per style
+// — see registry/styles/*.css); colors/spacing/animation stay inline since they
+// track the theme, not the style.
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return ;
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return ;
+}
+
+function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton ? (
+
+ }
+ >
+
+ Close
+
+ ) : null}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/bun.lock b/bun.lock
index 883653b..926b149 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,7 @@
"@tabler/icons-react": "^3.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "embla-carousel-react": "^8.6.0",
"isbot": "^5",
"lucide-react": "^1.21.0",
"motion": "^12.40.0",
@@ -641,6 +642,12 @@
"electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="],
+ "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
+
+ "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
+
+ "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
+
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
diff --git a/package.json b/package.json
index 11b1558..680d6b4 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@tabler/icons-react": "^3.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "embla-carousel-react": "^8.6.0",
"isbot": "^5",
"lucide-react": "^1.21.0",
"motion": "^12.40.0",
diff --git a/registry-dist/base-luma/modal.json b/registry-dist/base-luma/modal.json
new file mode 100644
index 0000000..ed7eef5
--- /dev/null
+++ b/registry-dist/base-luma/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-luma/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-lyra/modal.json b/registry-dist/base-lyra/modal.json
new file mode 100644
index 0000000..6facda6
--- /dev/null
+++ b/registry-dist/base-lyra/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-lyra/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-maia/modal.json b/registry-dist/base-maia/modal.json
new file mode 100644
index 0000000..267a373
--- /dev/null
+++ b/registry-dist/base-maia/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-maia/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-mira/modal.json b/registry-dist/base-mira/modal.json
new file mode 100644
index 0000000..14d1225
--- /dev/null
+++ b/registry-dist/base-mira/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-mira/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-nova/modal.json b/registry-dist/base-nova/modal.json
new file mode 100644
index 0000000..efe76a1
--- /dev/null
+++ b/registry-dist/base-nova/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-nova/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-rhea/modal.json b/registry-dist/base-rhea/modal.json
new file mode 100644
index 0000000..a210ae4
--- /dev/null
+++ b/registry-dist/base-rhea/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-rhea/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-sera/modal.json b/registry-dist/base-sera/modal.json
new file mode 100644
index 0000000..892fd08
--- /dev/null
+++ b/registry-dist/base-sera/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-sera/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/base-vega/modal.json b/registry-dist/base-vega/modal.json
new file mode 100644
index 0000000..ebe9c01
--- /dev/null
+++ b/registry-dist/base-vega/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/base-vega/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-luma/modal.json b/registry-dist/radix-luma/modal.json
new file mode 100644
index 0000000..7932c99
--- /dev/null
+++ b/registry-dist/radix-luma/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-luma/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-lyra/modal.json b/registry-dist/radix-lyra/modal.json
new file mode 100644
index 0000000..70b6257
--- /dev/null
+++ b/registry-dist/radix-lyra/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-lyra/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-maia/modal.json b/registry-dist/radix-maia/modal.json
new file mode 100644
index 0000000..6344923
--- /dev/null
+++ b/registry-dist/radix-maia/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-maia/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-mira/modal.json b/registry-dist/radix-mira/modal.json
new file mode 100644
index 0000000..4cd5179
--- /dev/null
+++ b/registry-dist/radix-mira/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-mira/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-nova/modal.json b/registry-dist/radix-nova/modal.json
new file mode 100644
index 0000000..5fccbf3
--- /dev/null
+++ b/registry-dist/radix-nova/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-nova/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-rhea/modal.json b/registry-dist/radix-rhea/modal.json
new file mode 100644
index 0000000..6419ed2
--- /dev/null
+++ b/registry-dist/radix-rhea/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-rhea/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-sera/modal.json b/registry-dist/radix-sera/modal.json
new file mode 100644
index 0000000..c27d7cb
--- /dev/null
+++ b/registry-dist/radix-sera/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-sera/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry-dist/radix-vega/modal.json b/registry-dist/radix-vega/modal.json
new file mode 100644
index 0000000..43f289a
--- /dev/null
+++ b/registry-dist/radix-vega/modal.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.",
+ "dependencies": [],
+ "registryDependencies": [
+ "dialog",
+ "button",
+ "carousel"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-vega/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// …\n// …\n// \n// …\n// …\n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// …\n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n"
+ }
+ ],
+ "css": {
+ "@keyframes modal-view-forward": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(0.5rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "@keyframes modal-view-back": {
+ "from": {
+ "opacity": "0",
+ "transform": "translateY(-0.375rem)"
+ },
+ "to": {
+ "opacity": "1",
+ "transform": "translateY(0)"
+ }
+ },
+ "[data-slot=modal-view]": {
+ "animation": "modal-view-forward 200ms ease-out"
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back"
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ "animation": "none"
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": {
+ "animation": "none"
+ }
+ }
+ }
+}
diff --git a/registry.json b/registry.json
index fa227f3..86d8deb 100644
--- a/registry.json
+++ b/registry.json
@@ -30,6 +30,21 @@
"target": "components/ui/choicebox.tsx"
}
]
+ },
+ {
+ "name": "modal",
+ "type": "registry:ui",
+ "title": "Modal",
+ "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable. Base-agnostic — it composes the consumer's dialog + button + carousel.",
+ "dependencies": [],
+ "registryDependencies": ["dialog", "button", "carousel"],
+ "files": [
+ {
+ "path": "registry/bases/base/ui/modal.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/modal.tsx"
+ }
+ ]
}
]
}
diff --git a/registry/bases/base/ui/modal.tsx b/registry/bases/base/ui/modal.tsx
new file mode 100644
index 0000000..bb7334b
--- /dev/null
+++ b/registry/bases/base/ui/modal.tsx
@@ -0,0 +1,539 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+import { IconPlaceholder } from "@/ui/icon-placeholder";
+import { Button } from "@/ui/button";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ type CarouselApi,
+} from "@/ui/carousel";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ DialogTrigger,
+} from "@/ui/dialog";
+
+// Modal — the composable modal-layout system: a namespaced compound family that
+// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes
+// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds
+// the cohesive *layout* every dialog should share: a pinned header, a single
+// scrolling body, an optional muted-aside second column, a slidable carousel
+// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned
+// footer, all combinable.
+//
+// This is the registry SOURCE: it is intentionally icon-library agnostic
+// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track
+// the theme and the dialog's corner radius is the one style-tunable token
+// (cn-dialog-content). The replace-style view transition rides the
+// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel
+// track is the consumer's `carousel` primitive (a registry dependency).
+//
+// References (the resolved, app-level implementations this is generalized from):
+// - Post for Me · post-for-me-dashboard (app modal-layout system)
+// - DXLogic · web/app/components/modal (Modal / ModalViews family)
+//
+// Anatomy (compound):
+//
+// Open} />
+//
+//
+// …
+// …
+//
+// …
+// …
+//
+//
+
+// `layout="framed"` (default) makes the popup a bounded flex column so the
+// header/footer pin and the body owns the only scroll; `layout="simple"` keeps
+// the plain dialog box. The layout rides ModalLayoutContext so the header/footer
+// self-pad only when framed.
+type ModalLayout = "simple" | "framed";
+
+const ModalLayoutContext = React.createContext("framed");
+
+function useModalLayout() {
+ return React.useContext(ModalLayoutContext);
+}
+
+// Root + trigger + close + a11y title/description are the Dialog primitives,
+// re-exported under the Modal namespace so a consumer assembles one family.
+const Modal = Dialog;
+const ModalTrigger = DialogTrigger;
+const ModalClose = DialogClose;
+const ModalTitle = DialogTitle;
+const ModalDescription = DialogDescription;
+
+function ModalContent({
+ layout = "framed",
+ className,
+ children,
+ ...props
+}: React.ComponentProps & { layout?: ModalLayout }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ModalHeader({ className, ...props }: React.ComponentProps<"div">) {
+ const layout = useModalLayout();
+ return (
+
+ );
+}
+
+function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+// The body split into two columns: a primary ModalColumn and a distinguished
+// ModalAside (muted panel). Container-query responsive — side-by-side when the
+// modal is wide, stacked when narrow.
+//
+// Flex (not grid) so the columns stay bounded to the available height and scroll
+// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the
+// height is definite via flexbox. `items-stretch` makes both columns full-height,
+// so the aside's panel background fills the whole side.
+function ModalColumns({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalColumn({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalAside({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
+ const layout = useModalLayout();
+ return (
+
+ );
+}
+
+// ModalCarousel — the slidable variation: ordered horizontal slides with a
+// deliberate (button-driven) step, generalizing onboarding / tour carousels.
+// Drag is off — stepping is via ModalCarouselNav.
+//
+// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track
+// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with
+// ModalCarouselDots + ModalCarouselNav all read the same carousel state:
+//
+//
+//
+//
+// …
+//
+//
+//
+//
+//
+//
+//
+//
+// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down
+// within a step.
+type ModalCarouselContextValue = {
+ index: number;
+ total: number;
+ isFirst: boolean;
+ isLast: boolean;
+ setApi: (api: CarouselApi) => void;
+ scrollNext: () => void;
+ scrollPrev: () => void;
+};
+
+const ModalCarouselContext =
+ React.createContext(null);
+
+function useModalCarousel() {
+ const ctx = React.useContext(ModalCarouselContext);
+ if (!ctx) {
+ throw new Error("useModalCarousel must be used within ");
+ }
+ return ctx;
+}
+
+function ModalCarousel({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ const [api, setApi] = React.useState();
+ const [index, setIndex] = React.useState(0);
+ const [total, setTotal] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!api) return;
+ const update = () => {
+ setIndex(api.selectedScrollSnap());
+ setTotal(api.scrollSnapList().length);
+ };
+ update();
+ api.on("select", update);
+ api.on("reInit", update);
+ return () => {
+ api.off("select", update);
+ api.off("reInit", update);
+ };
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
+ const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// The embla track. Lives inside ModalCarousel; holds ModalSlides.
+function ModalCarouselViewport({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ const { setApi } = useModalCarousel();
+ return (
+
+ {children}
+
+ );
+}
+
+function ModalSlide({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalCarouselDots({ className }: { className?: string }) {
+ const { index, total } = useModalCarousel();
+ return (
+
+ {Array.from({ length: total }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function ModalCarouselNav({
+ backLabel = "Back",
+ nextLabel = "Next",
+ finishLabel = "Finish",
+ onFinish,
+ className,
+}: {
+ backLabel?: string;
+ nextLabel?: string;
+ finishLabel?: string;
+ onFinish?: () => void;
+ className?: string;
+}) {
+ const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();
+ return (
+
+ {!isFirst ? (
+
+ ) : null}
+ {isLast ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// ModalViews — the replace-style inner navigation: a push/pop view stack that
+// swaps the active view *in place* (a subtle transition, NOT a horizontal
+// track). Use it when a dialog drills into sub-views and back (a settings panel,
+// a branching wizard).
+//
+// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a
+// destination rendered only when active; `useModalViews` drives navigation
+// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when
+// there's nothing to pop.
+type ModalViewsDirection = "forward" | "back" | "none";
+
+type ModalViewsContextValue = {
+ active: string;
+ stack: string[];
+ canGoBack: boolean;
+ direction: ModalViewsDirection;
+ push: (view: string) => void;
+ pop: () => void;
+ replace: (view: string) => void;
+ reset: (view?: string) => void;
+};
+
+const ModalViewsContext = React.createContext(
+ null
+);
+
+function useModalViews() {
+ const ctx = React.useContext(ModalViewsContext);
+ if (!ctx) {
+ throw new Error("useModalViews must be used within ");
+ }
+ return ctx;
+}
+
+function ModalViews({
+ defaultView,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & { defaultView: string }) {
+ const [stack, setStack] = React.useState([defaultView]);
+ const [direction, setDirection] = React.useState("none");
+ const active = stack[stack.length - 1];
+
+ const push = React.useCallback((view: string) => {
+ setDirection("forward");
+ setStack((s) => [...s, view]);
+ }, []);
+ const pop = React.useCallback(() => {
+ setDirection("back");
+ setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
+ }, []);
+ const replace = React.useCallback((view: string) => {
+ setDirection("forward");
+ setStack((s) => [...s.slice(0, -1), view]);
+ }, []);
+ const reset = React.useCallback(
+ (view?: string) => {
+ setDirection("back");
+ setStack([view ?? defaultView]);
+ },
+ [defaultView]
+ );
+
+ return (
+ 1,
+ direction,
+ push,
+ pop,
+ replace,
+ reset,
+ }}
+ >
+
+ {children}
+
+
+ );
+}
+
+function ModalView({
+ value,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & { value: string }) {
+ const { active, direction } = useModalViews();
+ if (active !== value) return null;
+ return (
+ // `key` remounts on view change so the enter animation replays; only the
+ // active view is mounted (a true replace, not a track). `data-direction`
+ // (see this item's css) gives push vs pop a slightly different in-place
+ // motion.
+
+ );
+}
+
+function ModalViewsBack({
+ className,
+ label = "Back",
+ ...props
+}: React.ComponentProps & { label?: string }) {
+ const { canGoBack, pop } = useModalViews();
+ if (!canGoBack) return null;
+ return (
+
+ );
+}
+
+export {
+ Modal,
+ ModalTrigger,
+ ModalClose,
+ ModalContent,
+ ModalHeader,
+ ModalTitle,
+ ModalDescription,
+ ModalBody,
+ ModalColumns,
+ ModalColumn,
+ ModalAside,
+ ModalFooter,
+ ModalCarousel,
+ ModalCarouselViewport,
+ ModalSlide,
+ ModalCarouselDots,
+ ModalCarouselNav,
+ ModalViews,
+ ModalView,
+ ModalViewsBack,
+ useModalLayout,
+ useModalCarousel,
+ useModalViews,
+ type ModalLayout,
+};
diff --git a/registry/bases/radix/ui/modal.tsx b/registry/bases/radix/ui/modal.tsx
new file mode 100644
index 0000000..bb7334b
--- /dev/null
+++ b/registry/bases/radix/ui/modal.tsx
@@ -0,0 +1,539 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+import { IconPlaceholder } from "@/ui/icon-placeholder";
+import { Button } from "@/ui/button";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ type CarouselApi,
+} from "@/ui/carousel";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ DialogTrigger,
+} from "@/ui/dialog";
+
+// Modal — the composable modal-layout system: a namespaced compound family that
+// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes
+// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds
+// the cohesive *layout* every dialog should share: a pinned header, a single
+// scrolling body, an optional muted-aside second column, a slidable carousel
+// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned
+// footer, all combinable.
+//
+// This is the registry SOURCE: it is intentionally icon-library agnostic
+// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track
+// the theme and the dialog's corner radius is the one style-tunable token
+// (cn-dialog-content). The replace-style view transition rides the
+// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel
+// track is the consumer's `carousel` primitive (a registry dependency).
+//
+// References (the resolved, app-level implementations this is generalized from):
+// - Post for Me · post-for-me-dashboard (app modal-layout system)
+// - DXLogic · web/app/components/modal (Modal / ModalViews family)
+//
+// Anatomy (compound):
+//
+// Open} />
+//
+//
+// …
+// …
+//
+// …
+// …
+//
+//
+
+// `layout="framed"` (default) makes the popup a bounded flex column so the
+// header/footer pin and the body owns the only scroll; `layout="simple"` keeps
+// the plain dialog box. The layout rides ModalLayoutContext so the header/footer
+// self-pad only when framed.
+type ModalLayout = "simple" | "framed";
+
+const ModalLayoutContext = React.createContext("framed");
+
+function useModalLayout() {
+ return React.useContext(ModalLayoutContext);
+}
+
+// Root + trigger + close + a11y title/description are the Dialog primitives,
+// re-exported under the Modal namespace so a consumer assembles one family.
+const Modal = Dialog;
+const ModalTrigger = DialogTrigger;
+const ModalClose = DialogClose;
+const ModalTitle = DialogTitle;
+const ModalDescription = DialogDescription;
+
+function ModalContent({
+ layout = "framed",
+ className,
+ children,
+ ...props
+}: React.ComponentProps & { layout?: ModalLayout }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ModalHeader({ className, ...props }: React.ComponentProps<"div">) {
+ const layout = useModalLayout();
+ return (
+
+ );
+}
+
+function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+// The body split into two columns: a primary ModalColumn and a distinguished
+// ModalAside (muted panel). Container-query responsive — side-by-side when the
+// modal is wide, stacked when narrow.
+//
+// Flex (not grid) so the columns stay bounded to the available height and scroll
+// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the
+// height is definite via flexbox. `items-stretch` makes both columns full-height,
+// so the aside's panel background fills the whole side.
+function ModalColumns({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalColumn({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalAside({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
+ const layout = useModalLayout();
+ return (
+
+ );
+}
+
+// ModalCarousel — the slidable variation: ordered horizontal slides with a
+// deliberate (button-driven) step, generalizing onboarding / tour carousels.
+// Drag is off — stepping is via ModalCarouselNav.
+//
+// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track
+// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with
+// ModalCarouselDots + ModalCarouselNav all read the same carousel state:
+//
+//
+//
+//
+// …
+//
+//
+//
+//
+//
+//
+//
+//
+// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down
+// within a step.
+type ModalCarouselContextValue = {
+ index: number;
+ total: number;
+ isFirst: boolean;
+ isLast: boolean;
+ setApi: (api: CarouselApi) => void;
+ scrollNext: () => void;
+ scrollPrev: () => void;
+};
+
+const ModalCarouselContext =
+ React.createContext(null);
+
+function useModalCarousel() {
+ const ctx = React.useContext(ModalCarouselContext);
+ if (!ctx) {
+ throw new Error("useModalCarousel must be used within ");
+ }
+ return ctx;
+}
+
+function ModalCarousel({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ const [api, setApi] = React.useState();
+ const [index, setIndex] = React.useState(0);
+ const [total, setTotal] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!api) return;
+ const update = () => {
+ setIndex(api.selectedScrollSnap());
+ setTotal(api.scrollSnapList().length);
+ };
+ update();
+ api.on("select", update);
+ api.on("reInit", update);
+ return () => {
+ api.off("select", update);
+ api.off("reInit", update);
+ };
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
+ const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// The embla track. Lives inside ModalCarousel; holds ModalSlides.
+function ModalCarouselViewport({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ const { setApi } = useModalCarousel();
+ return (
+
+ {children}
+
+ );
+}
+
+function ModalSlide({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ModalCarouselDots({ className }: { className?: string }) {
+ const { index, total } = useModalCarousel();
+ return (
+
+ {Array.from({ length: total }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function ModalCarouselNav({
+ backLabel = "Back",
+ nextLabel = "Next",
+ finishLabel = "Finish",
+ onFinish,
+ className,
+}: {
+ backLabel?: string;
+ nextLabel?: string;
+ finishLabel?: string;
+ onFinish?: () => void;
+ className?: string;
+}) {
+ const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();
+ return (
+
+ {!isFirst ? (
+
+ ) : null}
+ {isLast ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// ModalViews — the replace-style inner navigation: a push/pop view stack that
+// swaps the active view *in place* (a subtle transition, NOT a horizontal
+// track). Use it when a dialog drills into sub-views and back (a settings panel,
+// a branching wizard).
+//
+// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a
+// destination rendered only when active; `useModalViews` drives navigation
+// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when
+// there's nothing to pop.
+type ModalViewsDirection = "forward" | "back" | "none";
+
+type ModalViewsContextValue = {
+ active: string;
+ stack: string[];
+ canGoBack: boolean;
+ direction: ModalViewsDirection;
+ push: (view: string) => void;
+ pop: () => void;
+ replace: (view: string) => void;
+ reset: (view?: string) => void;
+};
+
+const ModalViewsContext = React.createContext(
+ null
+);
+
+function useModalViews() {
+ const ctx = React.useContext(ModalViewsContext);
+ if (!ctx) {
+ throw new Error("useModalViews must be used within ");
+ }
+ return ctx;
+}
+
+function ModalViews({
+ defaultView,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & { defaultView: string }) {
+ const [stack, setStack] = React.useState([defaultView]);
+ const [direction, setDirection] = React.useState("none");
+ const active = stack[stack.length - 1];
+
+ const push = React.useCallback((view: string) => {
+ setDirection("forward");
+ setStack((s) => [...s, view]);
+ }, []);
+ const pop = React.useCallback(() => {
+ setDirection("back");
+ setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
+ }, []);
+ const replace = React.useCallback((view: string) => {
+ setDirection("forward");
+ setStack((s) => [...s.slice(0, -1), view]);
+ }, []);
+ const reset = React.useCallback(
+ (view?: string) => {
+ setDirection("back");
+ setStack([view ?? defaultView]);
+ },
+ [defaultView]
+ );
+
+ return (
+ 1,
+ direction,
+ push,
+ pop,
+ replace,
+ reset,
+ }}
+ >
+
+ {children}
+
+
+ );
+}
+
+function ModalView({
+ value,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & { value: string }) {
+ const { active, direction } = useModalViews();
+ if (active !== value) return null;
+ return (
+ // `key` remounts on view change so the enter animation replays; only the
+ // active view is mounted (a true replace, not a track). `data-direction`
+ // (see this item's css) gives push vs pop a slightly different in-place
+ // motion.
+
+ );
+}
+
+function ModalViewsBack({
+ className,
+ label = "Back",
+ ...props
+}: React.ComponentProps & { label?: string }) {
+ const { canGoBack, pop } = useModalViews();
+ if (!canGoBack) return null;
+ return (
+
+ );
+}
+
+export {
+ Modal,
+ ModalTrigger,
+ ModalClose,
+ ModalContent,
+ ModalHeader,
+ ModalTitle,
+ ModalDescription,
+ ModalBody,
+ ModalColumns,
+ ModalColumn,
+ ModalAside,
+ ModalFooter,
+ ModalCarousel,
+ ModalCarouselViewport,
+ ModalSlide,
+ ModalCarouselDots,
+ ModalCarouselNav,
+ ModalViews,
+ ModalView,
+ ModalViewsBack,
+ useModalLayout,
+ useModalCarousel,
+ useModalViews,
+ type ModalLayout,
+};
diff --git a/registry/styles/style-luma.css b/registry/styles/style-luma.css
index 3f93fad..b5e331f 100644
--- a/registry/styles/style-luma.css
+++ b/registry/styles/style-luma.css
@@ -51,6 +51,9 @@
.cn-select-item {
@apply rounded-2xl;
}
+ .cn-dialog-content {
+ @apply rounded-3xl;
+ }
.cn-card {
@apply rounded-3xl;
}
diff --git a/registry/styles/style-lyra.css b/registry/styles/style-lyra.css
index 05ddbe7..ada5707 100644
--- a/registry/styles/style-lyra.css
+++ b/registry/styles/style-lyra.css
@@ -50,6 +50,9 @@
.cn-select-item {
@apply rounded-none;
}
+ .cn-dialog-content {
+ @apply rounded-none;
+ }
.cn-card {
@apply rounded-none;
}
diff --git a/registry/styles/style-maia.css b/registry/styles/style-maia.css
index c1d8683..a0c2b47 100644
--- a/registry/styles/style-maia.css
+++ b/registry/styles/style-maia.css
@@ -50,6 +50,9 @@
.cn-select-item {
@apply rounded-lg;
}
+ .cn-dialog-content {
+ @apply rounded-2xl;
+ }
.cn-card {
@apply rounded-2xl;
}
diff --git a/registry/styles/style-mira.css b/registry/styles/style-mira.css
index e16d88f..60a7989 100644
--- a/registry/styles/style-mira.css
+++ b/registry/styles/style-mira.css
@@ -63,6 +63,9 @@
.cn-select-item {
@apply rounded-md;
}
+ .cn-dialog-content {
+ @apply rounded-lg;
+ }
.cn-card {
@apply rounded-xl;
}
diff --git a/registry/styles/style-nova.css b/registry/styles/style-nova.css
index f1f30f4..f7e1755 100644
--- a/registry/styles/style-nova.css
+++ b/registry/styles/style-nova.css
@@ -49,6 +49,9 @@
.cn-select-item {
@apply rounded-md;
}
+ .cn-dialog-content {
+ @apply rounded-xl;
+ }
.cn-card {
@apply rounded-xl;
}
diff --git a/registry/styles/style-rhea.css b/registry/styles/style-rhea.css
index cbff236..9c2719e 100644
--- a/registry/styles/style-rhea.css
+++ b/registry/styles/style-rhea.css
@@ -50,6 +50,9 @@
.cn-select-item {
@apply rounded-xl;
}
+ .cn-dialog-content {
+ @apply rounded-2xl;
+ }
.cn-card {
@apply rounded-2xl;
}
diff --git a/registry/styles/style-sera.css b/registry/styles/style-sera.css
index 80fac8c..0b17edf 100644
--- a/registry/styles/style-sera.css
+++ b/registry/styles/style-sera.css
@@ -58,6 +58,9 @@
.cn-select-item {
@apply rounded-none;
}
+ .cn-dialog-content {
+ @apply rounded-none;
+ }
.cn-card {
@apply rounded-none;
}
diff --git a/registry/styles/style-vega.css b/registry/styles/style-vega.css
index 4ef7507..e09ac49 100644
--- a/registry/styles/style-vega.css
+++ b/registry/styles/style-vega.css
@@ -49,6 +49,9 @@
.cn-select-item {
@apply rounded-md;
}
+ .cn-dialog-content {
+ @apply rounded-xl;
+ }
.cn-card {
@apply rounded-xl;
}
diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts
index f2cc629..07eaf83 100644
--- a/scripts/build-registry.ts
+++ b/scripts/build-registry.ts
@@ -30,6 +30,12 @@ type Item = {
title: string;
description: string;
dependencies: string[];
+ // Other registry items this one installs alongside (shadcn `shadcn add`
+ // resolves these too) — e.g. modal builds on the consumer's dialog + button.
+ registryDependencies?: string[];
+ // Arbitrary CSS the item injects into the consumer's stylesheet (keyframes,
+ // data-slot rules). Same shape shadcn's registry-item `css` field expects.
+ css?: Record;
// Source path relative to registry/bases//, and install target.
file: string;
target: string;
@@ -61,6 +67,39 @@ const CHOICEBOX_BASE_DESCRIPTION =
const CHOICEBOX_RADIX_DESCRIPTION =
"The Radix variant of Choicebox, built on the Radix Toggle Group (type=\"single\" | \"multiple\").";
+const MODAL_DESCRIPTION =
+ "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.";
+
+// Modal source is base-agnostic (it composes the consumer's dialog + button +
+// carousel via `@/ui/*`, never a base primitive), so both variants ship the same
+// file and pull in whichever primitives the consumer's base provides.
+const MODAL_REGISTRY_DEPENDENCIES = ["dialog", "button", "carousel"];
+
+// The ModalViews replace-style transition. Injected into the consumer's
+// stylesheet on install; the showcase mirrors it in app/app.css.
+const MODAL_CSS: Record = {
+ "@keyframes modal-view-forward": {
+ from: { opacity: "0", transform: "translateY(0.5rem)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "@keyframes modal-view-back": {
+ from: { opacity: "0", transform: "translateY(-0.375rem)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "[data-slot=modal-view]": {
+ animation: "modal-view-forward 200ms ease-out",
+ },
+ "[data-slot=modal-view][data-direction=back]": {
+ "animation-name": "modal-view-back",
+ },
+ "[data-slot=modal-view][data-direction=none]": {
+ animation: "none",
+ },
+ "@media (prefers-reduced-motion: reduce)": {
+ "[data-slot=modal-view]": { animation: "none" },
+ },
+};
+
const BASES: Base[] = [
{
name: "base",
@@ -74,6 +113,17 @@ const BASES: Base[] = [
file: "ui/choicebox.tsx",
target: "components/ui/choicebox.tsx",
},
+ {
+ name: "modal",
+ type: "registry:ui",
+ title: "Modal",
+ description: MODAL_DESCRIPTION,
+ dependencies: [],
+ registryDependencies: MODAL_REGISTRY_DEPENDENCIES,
+ css: MODAL_CSS,
+ file: "ui/modal.tsx",
+ target: "components/ui/modal.tsx",
+ },
],
},
{
@@ -88,6 +138,17 @@ const BASES: Base[] = [
file: "ui/choicebox.tsx",
target: "components/ui/choicebox.tsx",
},
+ {
+ name: "modal",
+ type: "registry:ui",
+ title: "Modal",
+ description: MODAL_DESCRIPTION,
+ dependencies: [],
+ registryDependencies: MODAL_REGISTRY_DEPENDENCIES,
+ css: MODAL_CSS,
+ file: "ui/modal.tsx",
+ target: "components/ui/modal.tsx",
+ },
],
},
];
@@ -147,6 +208,9 @@ async function build() {
title: item.title,
description: item.description,
dependencies: item.dependencies,
+ ...(item.registryDependencies
+ ? { registryDependencies: item.registryDependencies }
+ : {}),
files: [
{
path: `registry/${base.name}-${style}/${item.file}`,
@@ -155,6 +219,7 @@ async function build() {
content,
},
],
+ ...(item.css ? { css: item.css } : {}),
};
const dir = path.join(outRoot, `${base.name}-${style}`);
diff --git a/vite.config.ts b/vite.config.ts
index 3417b75..cd29268 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -33,6 +33,7 @@ export default defineConfig({
"@base-ui/react/toggle-group",
"@base-ui/react/select",
"@base-ui/react/tooltip",
+ "@base-ui/react/dialog",
],
},
});