All components
Family Drawer
modalsA multi-view drawer component with smooth animations, view navigation, and customizable content views
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/family-drawer.jsonUsage
"use client"
import {
FamilyDrawerRoot,
FamilyDrawerTrigger,
FamilyDrawerPortal,
FamilyDrawerOverlay,
FamilyDrawerContent,
FamilyDrawerAnimatedWrapper,
FamilyDrawerAnimatedContent,
FamilyDrawerClose,
FamilyDrawerHeader,
FamilyDrawerButton,
FamilyDrawerSecondaryButton,
FamilyDrawerViewContent,
useFamilyDrawer,
type ViewsRegistry,
} from "@/registry/cult-ui/family-drawer"
function ShareIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
)
}
function CopyIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)
}
function MailIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
)
}
function DefaultView() {
const { setView } = useFamilyDrawer()
return (
<FamilyDrawerAnimatedWrapper>
<FamilyDrawerHeader
icon={<ShareIcon />}
title="Share this item"
description="Choose how you'd like to share with others."
/>
<div className="mt-4 flex flex-col gap-2">
<FamilyDrawerButton onClick={() => setView("copy-link")}>
<CopyIcon />
Copy link
</FamilyDrawerButton>
<FamilyDrawerButton onClick={() => setView("send-email")}>
<MailIcon />
Send via email
</FamilyDrawerButton>
</div>
<FamilyDrawerSecondaryButton
className="mt-3 text-muted-foreground"
onClick={() => {}}
>
Cancel
</FamilyDrawerSecondaryButton>
</FamilyDrawerAnimatedWrapper>
)
}
function CopyLinkView() {
const { setView } = useFamilyDrawer()
return (
<FamilyDrawerAnimatedWrapper>
<FamilyDrawerHeader
icon={<CopyIcon />}
title="Link copied!"
description="The shareable link has been copied to your clipboard."
/>
<div className="mt-4 p-3 rounded-xl bg-muted text-sm font-mono text-muted-foreground truncate">
https://myapp.com/share/abc123
</div>
<FamilyDrawerSecondaryButton
className="mt-4 text-muted-foreground"
onClick={() => setView("default")}
>
Back
</FamilyDrawerSecondaryButton>
</FamilyDrawerAnimatedWrapper>
)
}
function SendEmailView() {
const { setView } = useFamilyDrawer()
return (
<FamilyDrawerAnimatedWrapper>
<FamilyDrawerHeader
icon={<MailIcon />}
title="Send via email"
description="Enter recipient email to share this item directly."
/>
<input
type="email"
placeholder="friend@example.com"
className="mt-4 w-full rounded-xl border border-border bg-muted px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<FamilyDrawerButton className="mt-3" onClick={() => setView("default")}>
Send
</FamilyDrawerButton>
<FamilyDrawerSecondaryButton
className="mt-2 text-muted-foreground"
onClick={() => setView("default")}
>
Back
</FamilyDrawerSecondaryButton>
</FamilyDrawerAnimatedWrapper>
)
}
const views: ViewsRegistry = {
default: DefaultView,
"copy-link": CopyLinkView,
"send-email": SendEmailView,
}
export default function Demo() {
return (
<div className="flex items-center justify-center w-full min-h-[480px]">
<FamilyDrawerRoot views={views}>
<FamilyDrawerTrigger>Share</FamilyDrawerTrigger>
<FamilyDrawerPortal>
<FamilyDrawerOverlay />
<FamilyDrawerContent>
<FamilyDrawerClose />
<FamilyDrawerAnimatedContent>
<FamilyDrawerViewContent />
</FamilyDrawerAnimatedContent>
</FamilyDrawerContent>
</FamilyDrawerPortal>
</FamilyDrawerRoot>
</div>
)
}Component source
"use client"
import {
createContext,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { Slot } from "@radix-ui/react-slot"
import clsx from "clsx"
import { AnimatePresence, motion } from "motion/react"
import useMeasure from "react-use-measure"
import { Drawer } from "vaul"
// ============================================================================
// Types
// ============================================================================
type ViewComponent = React.ComponentType<Record<string, unknown>>
interface ViewsRegistry {
[viewName: string]: ViewComponent
}
// ============================================================================
// Context
// ============================================================================
interface FamilyDrawerContextValue {
isOpen: boolean
view: string
setView: (view: string) => void
opacityDuration: number
elementRef: ReturnType<typeof useMeasure>[0]
bounds: ReturnType<typeof useMeasure>[1]
views: ViewsRegistry | undefined
}
const FamilyDrawerContext = createContext<FamilyDrawerContextValue | undefined>(
undefined
)
function useFamilyDrawer() {
const context = useContext(FamilyDrawerContext)
if (!context) {
throw new Error(
"FamilyDrawer components must be used within FamilyDrawerRoot"
)
}
return context
}
// ============================================================================
// Root Component
// ============================================================================
interface FamilyDrawerRootProps {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
defaultView?: string
onViewChange?: (view: string) => void
views?: ViewsRegistry
}
function FamilyDrawerRoot({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
defaultView = "default",
onViewChange,
views: customViews,
}: FamilyDrawerRootProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const [view, setView] = useState(defaultView)
const [elementRef, bounds] = useMeasure()
const previousHeightRef = useRef<number>(0)
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen
const setIsOpen = onOpenChange || setInternalOpen
const opacityDuration = useMemo(() => {
const currentHeight = bounds.height
const previousHeight = previousHeightRef.current
const MIN_DURATION = 0.15
const MAX_DURATION = 0.27
if (!previousHeightRef.current) {
previousHeightRef.current = currentHeight
return MIN_DURATION
}
const heightDifference = Math.abs(currentHeight - previousHeight)
previousHeightRef.current = currentHeight
const duration = Math.min(
Math.max(heightDifference / 500, MIN_DURATION),
MAX_DURATION
)
return duration
}, [bounds.height])
const handleViewChange = (newView: string) => {
setView(newView)
onViewChange?.(newView)
}
// Use custom views if provided, otherwise pass undefined
const views =
customViews && Object.keys(customViews).length > 0 ? customViews : undefined
const contextValue: FamilyDrawerContextValue = {
isOpen,
view,
setView: handleViewChange,
opacityDuration,
elementRef,
bounds,
views,
}
return (
<FamilyDrawerContext.Provider value={contextValue}>
<Drawer.Root open={isOpen} onOpenChange={setIsOpen}>
{children}
</Drawer.Root>
</FamilyDrawerContext.Provider>
)
}
// ============================================================================
// Trigger Component
// ============================================================================
interface FamilyDrawerTriggerProps {
children: ReactNode
asChild?: boolean
className?: string
}
function FamilyDrawerTrigger({
children,
asChild = false,
className,
}: FamilyDrawerTriggerProps) {
if (asChild) {
return (
<Drawer.Trigger asChild>
<Slot>{children}</Slot>
</Drawer.Trigger>
)
}
return (
<Drawer.Trigger asChild>
<button
className={clsx(
"fixed top-1/2 left-1/2 antialiased -translate-y-1/2 -translate-x-1/2 h-[44px] rounded-full border bg-background px-4 py-2 font-medium text-foreground transition-colors hover:bg-accent focus-visible:shadow-focus-ring-button md:font-medium cursor-pointer",
className
)}
type="button"
>
{children}
</button>
</Drawer.Trigger>
)
}
// ============================================================================
// Portal Component
// ============================================================================
function FamilyDrawerPortal({ children }: { children: ReactNode }) {
return <Drawer.Portal>{children}</Drawer.Portal>
}
// ============================================================================
// Overlay Component
// ============================================================================
interface FamilyDrawerOverlayProps {
className?: string
onClick?: () => void
}
function FamilyDrawerOverlay({ className, onClick }: FamilyDrawerOverlayProps) {
const { setView } = useFamilyDrawer()
return (
<Drawer.Overlay
className={clsx("fixed inset-0 z-10 bg-black/30", className)}
onClick={onClick || (() => setView("default"))}
/>
)
}
// ============================================================================
// Content Component
// ============================================================================
interface FamilyDrawerContentProps {
children: ReactNode
className?: string
asChild?: boolean
}
function FamilyDrawerContent({
children,
className,
asChild = false,
}: FamilyDrawerContentProps) {
const { bounds } = useFamilyDrawer()
const content = (
<motion.div
animate={{
height: bounds.height,
transition: {
duration: 0.27,
ease: [0.25, 1, 0.5, 1],
},
}}
>
{children}
</motion.div>
)
if (asChild) {
return (
<Drawer.Content
asChild
className={clsx(
"fixed inset-x-4 bottom-4 z-10 mx-auto max-w-[361px] overflow-hidden rounded-[36px] bg-background outline-none md:mx-auto md:w-full",
className
)}
>
<Slot>{content}</Slot>
</Drawer.Content>
)
}
return (
<Drawer.Content
asChild
className={clsx(
"fixed inset-x-4 bottom-4 z-10 mx-auto max-w-[361px] overflow-hidden rounded-[36px] bg-background outline-none md:mx-auto md:w-full",
className
)}
>
{content}
</Drawer.Content>
)
}
// ============================================================================
// Animated Wrapper Component
// ============================================================================
interface FamilyDrawerAnimatedWrapperProps {
children: ReactNode
className?: string
}
function FamilyDrawerAnimatedWrapper({
children,
className,
}: FamilyDrawerAnimatedWrapperProps) {
const { elementRef } = useFamilyDrawer()
return (
<div
ref={elementRef}
className={clsx("px-6 pb-6 pt-2.5 antialiased", className)}
>
{children}
</div>
)
}
// ============================================================================
// Animated Content Component
// ============================================================================
interface FamilyDrawerAnimatedContentProps {
children: ReactNode
}
function FamilyDrawerAnimatedContent({
children,
}: FamilyDrawerAnimatedContentProps) {
const { view, opacityDuration } = useFamilyDrawer()
return (
<AnimatePresence initial={false} mode="popLayout" custom={view}>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
key={view}
transition={{
duration: opacityDuration,
ease: [0.26, 0.08, 0.25, 1],
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
// ============================================================================
// Close Component
// ============================================================================
interface FamilyDrawerCloseProps {
children?: ReactNode
asChild?: boolean
className?: string
}
function FamilyDrawerClose({
children,
asChild = false,
className,
}: FamilyDrawerCloseProps) {
const defaultClose = (
<button
data-vaul-no-drag=""
className={clsx(
"absolute right-8 top-7 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground transition-transform focus:scale-95 focus-visible:shadow-focus-ring-button active:scale-75 cursor-pointer",
className
)}
type="button"
>
{children || <CloseIcon />}
</button>
)
if (asChild) {
return (
<Drawer.Close asChild>
<Slot>{defaultClose}</Slot>
</Drawer.Close>
)
}
return <Drawer.Close asChild>{defaultClose}</Drawer.Close>
}
// ============================================================================
// Helper Components
// ============================================================================
interface FamilyDrawerHeaderProps {
icon: ReactNode
title: string
description: string
className?: string
}
function FamilyDrawerHeader({
icon,
title,
description,
className,
}: FamilyDrawerHeaderProps) {
return (
<header className={clsx("mt-[21px]", className)}>
{icon}
<h2 className="mt-2.5 text-[22px] font-semibold text-foreground md:font-medium">
{title}
</h2>
<p className="mt-3 text-[17px] font-medium leading-[24px] text-muted-foreground md:font-normal">
{description}
</p>
</header>
)
}
interface FamilyDrawerButtonProps {
children: ReactNode
onClick: () => void
className?: string
asChild?: boolean
}
function FamilyDrawerButton({
children,
onClick,
className,
asChild = false,
}: FamilyDrawerButtonProps) {
const button = (
<button
data-vaul-no-drag=""
className={clsx(
"flex h-12 w-full items-center gap-[15px] rounded-[16px] bg-muted px-4 text-[17px] font-semibold text-foreground transition-transform focus:scale-95 focus-visible:shadow-focus-ring-button active:scale-95 md:font-medium cursor-pointer",
className
)}
onClick={onClick}
type="button"
>
{children}
</button>
)
if (asChild) {
return <Slot>{button}</Slot>
}
return button
}
interface FamilyDrawerSecondaryButtonProps {
children: ReactNode
onClick: () => void
className: string
asChild?: boolean
}
function FamilyDrawerSecondaryButton({
children,
onClick,
className,
asChild = false,
}: FamilyDrawerSecondaryButtonProps) {
const button = (
<button
data-vaul-no-drag=""
type="button"
className={clsx(
"flex h-12 w-full items-center justify-center gap-[15px] rounded-full text-center text-[19px] font-semibold transition-transform focus:scale-95 focus-visible:shadow-focus-ring-button active:scale-95 md:font-medium cursor-pointer",
className
)}
onClick={onClick}
>
{children}
</button>
)
if (asChild) {
return <Slot>{button}</Slot>
}
return button
}
// ============================================================================
// View Content Renderer
// ============================================================================
interface FamilyDrawerViewContentProps {
views?: ViewsRegistry
}
function FamilyDrawerViewContent(
{
views: propViews,
}: FamilyDrawerViewContentProps = {} as FamilyDrawerViewContentProps
) {
const { view, views: contextViews } = useFamilyDrawer()
// Use prop views first, then context views
const views = propViews || contextViews
if (!views) {
throw new Error(
"FamilyDrawerViewContent requires views to be provided via props or FamilyDrawerRoot"
)
}
const ViewComponent = views[view]
if (!ViewComponent) {
// Fallback to default view if view not found
const DefaultComponent = views.default
return DefaultComponent ? <DefaultComponent /> : null
}
return <ViewComponent />
}
// ============================================================================
// Icons
// ============================================================================
function CloseIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Close Icon</title>
<path
d="M10.4854 1.99998L2.00007 10.4853"
stroke="#999999"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.4854 10.4844L2.00007 1.99908"
stroke="#999999"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ============================================================================
// Exports
// ============================================================================
export {
FamilyDrawerRoot,
FamilyDrawerTrigger,
FamilyDrawerPortal,
FamilyDrawerOverlay,
FamilyDrawerContent,
FamilyDrawerAnimatedWrapper,
FamilyDrawerAnimatedContent,
FamilyDrawerClose,
FamilyDrawerHeader,
FamilyDrawerButton,
FamilyDrawerSecondaryButton,
FamilyDrawerViewContent,
useFamilyDrawer,
type ViewsRegistry,
type ViewComponent,
}Dependencies
motionreact-use-measurevaul@radix-ui/react-slot
Source: Cult UI