All components
Expandable Screen
modalsA full-screen expandable component with morphing animations using shared layout IDs for smooth transitions
responsive · 600px
Install
Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:
$
npx shadcn@latest add https://your-domain/r/expandable-screen.jsonUsage
"use client"
import {
ExpandableScreen,
ExpandableScreenTrigger,
ExpandableScreenContent,
} from "@/registry/cult-ui/expandable-screen"
export default function Demo() {
return (
<div className="flex items-center justify-center w-full min-h-[480px]">
<ExpandableScreen animationDuration={0.35}>
<ExpandableScreenTrigger>
<div className="group flex flex-col items-start gap-3 w-72 rounded-2xl border border-border bg-card p-5 shadow-sm cursor-pointer transition-shadow hover:shadow-md">
<img
src="https://images.unsplash.com/photo-1517849845537-4d257902454a?w=600"
alt="Preview"
className="w-full h-36 object-cover rounded-xl"
/>
<div>
<h3 className="font-semibold text-foreground text-base">
Expand to Full Screen
</h3>
<p className="text-sm text-muted-foreground mt-0.5">
Click this card to open an immersive view
</p>
</div>
<span className="text-xs font-medium text-primary">
Tap to expand →
</span>
</div>
</ExpandableScreenTrigger>
<ExpandableScreenContent className="bg-neutral-900 flex flex-col">
<div className="flex flex-col items-center justify-center h-full gap-6 px-8 text-center">
<img
src="https://images.unsplash.com/photo-1517849845537-4d257902454a?w=1200"
alt="Full view"
className="w-full max-w-2xl rounded-2xl object-cover max-h-64"
/>
<div className="text-white">
<h2 className="text-3xl font-bold mb-3">Full Screen Experience</h2>
<p className="text-neutral-300 max-w-md leading-relaxed">
This panel morphed from the card. Press the × button or
Escape to collapse back.
</p>
</div>
<button
className="mt-2 px-6 py-2.5 rounded-full bg-white text-neutral-900 font-medium text-sm hover:bg-neutral-100 transition-colors"
onClick={(e) => e.stopPropagation()}
>
Get Started
</button>
</div>
</ExpandableScreenContent>
</ExpandableScreen>
</div>
)
}Component source
"use client"
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react"
import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/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
}
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={`inline-block relative ${className}`}>
{/* Background layer with shared layoutId for morphing */}
<motion.div
style={{
borderRadius: triggerRadius,
}}
layout
layoutId={layoutId}
className="absolute inset-0 transform-gpu will-change-transform"
/>
{/* Content layer that fades out on expand */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
exit={{ opacity: 0, scale: 0.8 }}
layout={false}
onClick={expand}
className="relative cursor-pointer"
>
{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
layoutId={layoutId}
transition={{ duration: animationDuration }}
style={{
borderRadius: contentRadius,
}}
layout
className={`relative flex h-full w-full overflow-y-auto transform-gpu will-change-transform ${className}`}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.4 }}
className="relative z-20 w-full"
>
{children}
</motion.div>
{showCloseButton && (
<motion.button
onClick={collapse}
className={`absolute right-6 top-6 z-30 flex h-10 w-10 items-center justify-center transition-colors rounded-full ${
closeButtonClassName ||
"text-primary-foreground bg-transparent hover:bg-primary-foreground/10"
}`}
aria-label="Close"
>
<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 }Dependencies
motionlucide-react
Source: Cult UI