feat: implement ExpandableScreen component with trigger and content for interactive UI

This commit is contained in:
Marco Pedone 2026-01-07 16:45:23 +01:00
parent 7763aac01c
commit cf35fac146
2 changed files with 257 additions and 1 deletions

View file

@ -0,0 +1,228 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
// Context
interface ExpandableScreenContextValue {
isExpanded: boolean;
expand: () => void;
collapse: () => void;
layoutId: string;
triggerRadius: string;
contentRadius: string;
animationDuration: number;
}
const ExpandableScreenContext =
createContext<ExpandableScreenContextValue | null>(null);
function useExpandableScreen() {
const context = useContext(ExpandableScreenContext);
if (!context) {
throw new Error(
"useExpandableScreen must be used within an ExpandableScreen",
);
}
return context;
}
// Root Component
interface ExpandableScreenProps {
children: ReactNode;
defaultExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
layoutId?: string;
triggerRadius?: string;
contentRadius?: string;
animationDuration?: number;
lockScroll?: boolean;
}
/**
* @link https://www.cult-ui.com/docs/components/expandable-screen
* */
export function ExpandableScreen({
children,
defaultExpanded = false,
onExpandChange,
layoutId = "expandable-card",
triggerRadius = "100px",
contentRadius = "24px",
animationDuration = 0.3,
lockScroll = true,
}: ExpandableScreenProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const expand = () => {
setIsExpanded(true);
onExpandChange?.(true);
};
const collapse = () => {
setIsExpanded(false);
onExpandChange?.(false);
};
useEffect(() => {
if (lockScroll) {
if (isExpanded) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
}
}, [isExpanded, lockScroll]);
return (
<ExpandableScreenContext.Provider
value={{
isExpanded,
expand,
collapse,
layoutId,
triggerRadius,
contentRadius,
animationDuration,
}}
>
{children}
</ExpandableScreenContext.Provider>
);
}
// Trigger Component
interface ExpandableScreenTriggerProps {
children: ReactNode;
className?: string;
}
export function ExpandableScreenTrigger({
children,
className = "",
}: ExpandableScreenTriggerProps) {
const { isExpanded, expand, layoutId, triggerRadius } = useExpandableScreen();
return (
<AnimatePresence initial={false}>
{!isExpanded && (
<motion.div className={`relative inline-block ${className}`}>
{/* Background layer with shared layoutId for morphing */}
<motion.div
className="absolute inset-0 transform-gpu will-change-transform"
layout
layoutId={layoutId}
style={{
borderRadius: triggerRadius,
}}
/>
{/* Content layer that fades out on expand */}
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="relative cursor-pointer"
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
layout={false}
onClick={expand}
transition={{ delay: 0.2 }}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
// Content Component
interface ExpandableScreenContentProps {
children: ReactNode;
className?: string;
showCloseButton?: boolean;
closeButtonClassName?: string;
}
export function ExpandableScreenContent({
children,
className = "",
showCloseButton = true,
closeButtonClassName = "",
}: ExpandableScreenContentProps) {
const { isExpanded, collapse, layoutId, contentRadius, animationDuration } =
useExpandableScreen();
return (
<AnimatePresence initial={false}>
{isExpanded && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-2">
{/* Morphing background with shared layoutId */}
<motion.div
className={`relative flex h-full w-full transform-gpu overflow-y-auto will-change-transform ${className}`}
layout
layoutId={layoutId}
style={{
borderRadius: contentRadius,
}}
transition={{ duration: animationDuration }}
>
<motion.div
animate={{ opacity: 1 }}
className="relative z-20 w-full"
initial={{ opacity: 0 }}
transition={{ delay: 0.15, duration: 0.4 }}
>
{children}
</motion.div>
{showCloseButton && (
<motion.button
aria-label="Close"
className={`absolute top-6 right-6 z-30 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors ${
closeButtonClassName ||
"bg-transparent text-white hover:bg-white/10 dark:text-primary-foreground hover:dark:bg-primary-foreground/10"
}`}
onClick={collapse}
>
<X className="h-5 w-5" />
</motion.button>
)}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
// Background Component (optional)
interface ExpandableScreenBackgroundProps {
trigger?: ReactNode;
content?: ReactNode;
className?: string;
}
export function ExpandableScreenBackground({
trigger,
content,
className = "",
}: ExpandableScreenBackgroundProps) {
const { isExpanded } = useExpandableScreen();
if (isExpanded && content) {
return <div className={className}>{content}</div>;
}
if (!isExpanded && trigger) {
return <div className={className}>{trigger}</div>;
}
return null;
}
export { useExpandableScreen };

View file

@ -1,7 +1,35 @@
import {
ExpandableScreen,
ExpandableScreenContent,
ExpandableScreenTrigger,
} from "~/components/expandable_screen";
import { Button } from "~/components/ui/button";
import type { NextPageWithLayout } from "./_app"; import type { NextPageWithLayout } from "./_app";
const Test: NextPageWithLayout = () => { const Test: NextPageWithLayout = () => {
return <main className="flex flex-col gap-4 p-8">asdsad</main>; return (
<main className="flex flex-col gap-4 p-8">
<ExpandableScreen
contentRadius="14px"
layoutId="cta-card"
triggerRadius="100px"
>
<div className="flex min-h-screen items-center justify-center">
<ExpandableScreenTrigger>
<Button>Open Screen</Button>
</ExpandableScreenTrigger>
</div>
<ExpandableScreenContent className="bg-primary">
<div className="flex h-full items-center justify-center p-8">
<h2 className="text-4xl text-primary-foreground">
Full Screen Content
</h2>
</div>
</ExpandableScreenContent>
</ExpandableScreen>
</main>
);
}; };
export default Test; export default Test;