Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
9 changes: 9 additions & 0 deletions app/showcase/components/demos.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +23,14 @@ export const demos: Record<
preview: <ChoiceboxPreview />,
examples: <ChoiceboxExamples />,
},
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: <ModalPreview />,
examples: <ModalExamples />,
},
};

export const demoOrder = Object.keys(demos);
196 changes: 196 additions & 0 deletions app/showcase/components/modal-demo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal>
<ModalTrigger render={<Button variant="outline">Open dialog</Button>} />
<ModalContent layout="framed">
<ModalHeader>
<ModalTitle>Framed dialog</ModalTitle>
<ModalDescription>
Header and footer pin; the body owns the only scroll.
</ModalDescription>
</ModalHeader>
<ModalBody className="flex flex-col gap-3">
{PARAGRAPHS.map((p) => (
<p key={p} className="text-sm text-muted-foreground">
{p}
</p>
))}
</ModalBody>
<ModalFooter>
<ModalClose render={<Button variant="ghost">Cancel</Button>} />
<ModalClose render={<Button>Save</Button>} />
</ModalFooter>
</ModalContent>
</Modal>
);
}

export function ModalExamples() {
return (
<div className="space-y-8">
<Example
name="Two columns (muted aside)"
description="A primary column beside a distinguished muted panel — each scrolls independently; container-query responsive."
>
<Modal>
<ModalTrigger render={<Button variant="outline">Open</Button>} />
<ModalContent layout="framed" className="max-w-3xl">
<ModalHeader>
<ModalTitle>Choose an option</ModalTitle>
</ModalHeader>
<ModalColumns>
<ModalColumn className="flex flex-col gap-3">
{Array.from({ length: 6 }, (_, i) => (
<p key={i} className="text-sm text-muted-foreground">
Primary column item {i + 1}. This column scrolls
independently of the aside.
</p>
))}
</ModalColumn>
<ModalAside className="flex flex-col gap-2">
<p className="font-heading text-lg font-semibold text-foreground">
Summary
</p>
<p className="text-sm text-muted-foreground">
The trailing column is a distinguished muted panel — for a
value-prop, a preview, or a running summary.
</p>
</ModalAside>
</ModalColumns>
<ModalFooter>
<ModalClose render={<Button>Continue</Button>} />
</ModalFooter>
</ModalContent>
</Modal>
</Example>

<Example
name="Slidable carousel"
description="Ordered horizontal slides with a deliberate button-driven step — dots track progress; Back hides on the first slide and Next becomes Finish on the last."
>
<Modal>
<ModalTrigger render={<Button variant="outline">Open tour</Button>} />
<ModalContent layout="framed">
<ModalCarousel>
<ModalCarouselViewport>
{SLIDES.map((slide) => (
<ModalSlide key={slide.title}>
<div className="flex flex-col gap-2 px-6 py-10 text-center">
<p className="font-heading text-lg font-semibold text-foreground">
{slide.title}
</p>
<p className="text-sm text-muted-foreground">
{slide.body}
</p>
</div>
</ModalSlide>
))}
</ModalCarouselViewport>
<ModalFooter className="sm:justify-between">
<ModalCarouselDots />
<ModalCarouselNav />
</ModalFooter>
</ModalCarousel>
</ModalContent>
</Modal>
</Example>

<Example
name="Replace-style inner navigation"
description="A push/pop view stack that swaps the active view in place — Back hides when there's nothing to pop."
>
<Modal>
<ModalTrigger render={<Button variant="outline">Open</Button>} />
<ModalContent layout="framed">
<ModalViews defaultView="home">
<ModalHeader className="flex-row items-center gap-2 text-start sm:flex-row">
<ModalViewsBack />
<ModalTitle>Settings</ModalTitle>
</ModalHeader>
<ModalBody>
<ModalView value="home">
<ViewsHome />
</ModalView>
<ModalView value="profile">
<p className="text-sm text-muted-foreground">
Profile settings. The previous view is replaced in place;
Back pops the stack.
</p>
</ModalView>
<ModalView value="billing">
<p className="text-sm text-muted-foreground">
Billing settings. Each destination is its own view, mounted
only when active.
</p>
</ModalView>
</ModalBody>
</ModalViews>
</ModalContent>
</Modal>
</Example>
</div>
);
}

function ViewsHome() {
const { push } = useModalViews();
return (
<div className="flex flex-col gap-2">
<Button variant="outline" onClick={() => push("profile")}>
Profile
</Button>
<Button variant="outline" onClick={() => push("billing")}>
Billing
</Button>
</div>
);
}
Loading