# Expandable Screen

> React component for shadcn/ui that morphs a card or button into a full-screen view using Motion shared layout animations.

Source: https://www.cult-ui.com/docs/components/expandable-screen

## Example

```tsx title="expandable-screen-demo.tsx"
"use client"

import { useId } from "react"
import Image from "next/image"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import {
  ExpandableScreen,
  ExpandableScreenContent,
  ExpandableScreenTrigger,
} from "@/components/ui/expandable-screen"

const formFieldClassName =
  "w-full rounded-lg border-0 bg-primary-foreground px-4 text-primary shadow-none placeholder:text-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm"

function ExpandableScreenDemo() {
  const nameId = useId()
  const emailId = useId()
  const websiteId = useId()
  const companySizeId = useId()
  const messageId = useId()
  return (
    <ExpandableScreen
      layoutId="cta-card"
      triggerRadius="100px"
      contentRadius="24px"
    >
      <div className="relative flex min-h-screen flex-col items-center justify-center px-4 py-12 sm:px-6 sm:py-20">
        <div className="relative z-10 flex flex-col items-center gap-4 text-center sm:gap-6">
          <h1 className="text-foreground max-w-2xl text-4xl leading-[90%] font-normal tracking-[-0.03em] mix-blend-exclusion sm:text-5xl md:text-6xl lg:text-7xl">
            Join the waitlist
          </h1>

          <p className="text-foreground max-w-2xl px-4 text-base leading-[160%] sm:text-lg md:text-xl">
            Be among the first to experience our next-generation platform. Get
            early access to exclusive features and help shape the future of
            productivity.
          </p>

          <ExpandableScreenTrigger>
            <div className="bg-primary font-regular text-primary-foreground h-15 px-6 py-3 text-lg tracking-[-0.01em] sm:px-8 sm:text-xl">
              Get early access
            </div>
          </ExpandableScreenTrigger>
        </div>
      </div>

      <ExpandableScreenContent className="bg-primary">
        <div className="relative z-10 mx-auto flex h-full w-full max-w-[1100px] flex-col items-center gap-8 p-6 sm:p-10 lg:flex-row lg:gap-16 lg:p-16">
          <div className="flex w-full flex-1 flex-col justify-center space-y-3">
            <h2 className="text-primary-foreground text-3xl leading-none font-medium tracking-[-0.03em] sm:text-4xl lg:text-5xl">
              Reserve your spot
            </h2>

            <div className="space-y-4 pt-4 sm:space-y-6">
              <div className="flex gap-3 sm:gap-4">
                <div className="bg-primary-foreground/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg sm:h-12 sm:w-12">
                  <svg
                    className="text-primary-foreground h-5 w-5 sm:h-6 sm:w-6"
                    fill="none"
                    viewBox="0 0 24 24"
                    stroke="currentColor"
                  >
                    <title>Icon</title>
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M5 13l4 4L19 7"
                    />
                  </svg>
                </div>
                <div>
                  <p className="text-primary-foreground text-sm leading-[150%] sm:text-base">
                    Get priority access to new features and updates before
                    public release.
                  </p>
                </div>
              </div>
              <div className="flex gap-3 sm:gap-4">
                <div className="bg-primary-foreground/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg sm:h-12 sm:w-12">
                  <svg
                    className="text-primary-foreground h-5 w-5 sm:h-6 sm:w-6"
                    fill="none"
                    viewBox="0 0 24 24"
                    stroke="currentColor"
                  >
                    <title>Icon</title>
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M13 10V3L4 14h7v7l9-11h-7z"
                    />
                  </svg>
                </div>
                <div>
                  <p className="text-primary-foreground text-sm leading-[150%] sm:text-base">
                    Join a community of early adopters and help influence our
                    product roadmap.
                  </p>
                </div>
              </div>
            </div>

            <div className="border-primary-foreground/20 mt-6 border-t pt-6 sm:mt-8 sm:pt-8">
              <p className="text-primary-foreground mb-4 text-lg leading-[150%] sm:text-xl lg:text-2xl">
                The waitlist has been a game-changer for our workflow. Highly
                recommend joining early.
              </p>
              <div className="flex items-center gap-3 sm:gap-4">
                <Image
                  src="/placeholder.svg?height=48&width=48"
                  alt="Alex Rivera"
                  width={48}
                  height={48}
                  className="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12"
                />
                <div>
                  <p className="text-primary-foreground text-base sm:text-lg lg:text-xl">
                    Alex Rivera
                  </p>
                  <p className="text-primary-foreground/70 text-sm sm:text-base">
                    Early Access Member
                  </p>
                </div>
              </div>
            </div>
          </div>

          <div className="w-full flex-1">
            <form className="space-y-4 sm:space-y-5">
              <div>
                <Label
                  htmlFor={nameId}
                  className="text-primary-foreground mb-2 block font-mono text-[10px] font-normal tracking-[0.5px] uppercase"
                >
                  FULL NAME *
                </Label>
                <Input
                  type="text"
                  id={nameId}
                  name="name"
                  className={cn(formFieldClassName, "h-10 py-2.5")}
                />
              </div>

              <div>
                <Label
                  htmlFor={emailId}
                  className="text-primary-foreground mb-2 block font-mono text-[10px] font-normal tracking-[0.5px] uppercase"
                >
                  EMAIL *
                </Label>
                <Input
                  type="email"
                  id={emailId}
                  name="email"
                  className={cn(formFieldClassName, "h-10 py-2.5")}
                />
              </div>

              <div className="flex flex-col gap-4 sm:flex-row">
                <div className="flex-1">
                  <Label
                    htmlFor={websiteId}
                    className="text-primary-foreground mb-2 block font-mono text-[10px] font-normal tracking-[0.5px] uppercase"
                  >
                    USE CASE
                  </Label>
                  <Input
                    type="text"
                    id={websiteId}
                    name="use-case"
                    placeholder="e.g., Project management, Team collaboration"
                    className={cn(formFieldClassName, "h-10 py-2.5")}
                  />
                </div>
                <div className="w-full sm:w-32">
                  <Label
                    htmlFor={companySizeId}
                    className="text-primary-foreground mb-2 block font-mono text-[10px] font-normal tracking-[0.5px] uppercase"
                  >
                    TEAM SIZE
                  </Label>
                  <Select name="team-size">
                    <SelectTrigger
                      id={companySizeId}
                      className={cn(formFieldClassName, "h-10 py-2.5")}
                    >
                      <SelectValue placeholder="Select" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="solo">Solo</SelectItem>
                      <SelectItem value="2-5">2-5</SelectItem>
                      <SelectItem value="6-20">6-20</SelectItem>
                      <SelectItem value="21-50">21-50</SelectItem>
                      <SelectItem value="50+">50+</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
              </div>

              <div>
                <Label
                  htmlFor={messageId}
                  className="text-primary-foreground mb-2 block font-mono text-[10px] font-normal tracking-[0.5px] uppercase"
                >
                  WHAT ARE YOU MOST EXCITED ABOUT?
                </Label>
                <Textarea
                  id={messageId}
                  name="excited-about"
                  rows={3}
                  placeholder="Tell us what features you're looking forward to..."
                  className={cn(formFieldClassName, "resize-none py-3")}
                />
              </div>

              <Button
                type="submit"
                className="bg-primary-foreground text-primary hover:bg-primary-foreground/90 h-10 w-full rounded-full px-8 py-2.5 font-medium tracking-[-0.03em] transition-colors"
              >
                Join waitlist
              </Button>
            </form>
          </div>
        </div>
      </ExpandableScreenContent>
    </ExpandableScreen>
  )
}

export default ExpandableScreenDemo
```

ExpandableScreen is a React component for shadcn/ui built with Motion shared layout IDs. Use it for product detail views, onboarding flows, or sign-up forms that open from a card or button.

## References

<Citations>
  <CitationTitle>Inspiration</CitationTitle>
  <CitationList>
    <CitationItem>
      <CitationLink
        href="https://animations.dev/"
        target="_blank"
        rel="noopener noreferrer"
      >
        Animations.dev - Morphing Animations
      </CitationLink>
    </CitationItem>
    <CitationItem>
      <CitationLink
        href="https://v0.app/@joelbqz"
        target="_blank"
        rel="noopener noreferrer"
      >
        v0.app/@joelbqz - Expandable Screen Component
      </CitationLink>
    </CitationItem>
  </CitationList>
</Citations>

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/expandable-screen
```

### Manual

**Copy and paste the following code into your project.**

```tsx title="expandable-screen.tsx"
"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={`relative inline-block ${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 transform-gpu overflow-y-auto 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 top-6 right-6 z-30 flex h-10 w-10 items-center justify-center rounded-full transition-colors ${
                  closeButtonClassName ||
                  "text-primary-foreground hover:bg-primary-foreground/10 bg-transparent"
                }`}
                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 }
```

**Update the import paths to match your project setup.**

## Usage

The ExpandableScreen component creates a morphing animation effect where a trigger element smoothly expands into a full-screen overlay. It uses Framer Motion's `layoutId` feature to create seamless transitions between states.

### Basic Example

```tsx
import {
  ExpandableScreen,
  ExpandableScreenContent,
  ExpandableScreenTrigger,
} from "@/components/ui/expandable-screen";

export default function ExpandableScreenDemo() {
  return (
    <ExpandableScreen
      layoutId="cta-card"
      triggerRadius="100px"
      contentRadius="24px"
    >
      <div className="flex min-h-screen items-center justify-center">
        <ExpandableScreenTrigger>
          <button className="bg-primary px-6 py-3 text-primary-foreground">
            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>
  );
}
```

## Components

### ExpandableScreen

The root component that provides context and manages the expanded state. It uses a shared `layoutId` to create morphing animations between the trigger and content.

```typescriptreact
<ExpandableScreen
  layoutId="unique-id"
  triggerRadius="100px"
  contentRadius="24px"
  animationDuration={0.3}
>
  {/* Child components */}
</ExpandableScreen>
```

**Props:**

- `layoutId?: string` - Unique identifier for the shared layout animation (default: "expandable-card")
- `triggerRadius?: string` - Border radius for the trigger element (default: "100px")
- `contentRadius?: string` - Border radius for the expanded content (default: "24px")
- `animationDuration?: number` - Duration of the morphing animation in seconds (default: 0.3)
- `defaultExpanded?: boolean` - Initial expanded state (default: false)
- `onExpandChange?: (expanded: boolean) => void` - Callback when expanded state changes
- `lockScroll?: boolean` - Whether to lock body scroll when expanded (default: true)

### ExpandableScreenTrigger

The trigger element that expands into the full screen. This component automatically hides when expanded and shows when collapsed.

```typescriptreact
<ExpandableScreenTrigger>
  <button>Click to Expand</button>
</ExpandableScreenTrigger>
```

**Props:**

- `className?: string` - Additional CSS classes

**Note:** The trigger automatically disappears when the screen is expanded and reappears when collapsed.

### ExpandableScreenContent

The full-screen content that appears when expanded. It morphs from the trigger element using the shared `layoutId`.

```typescriptreact
<ExpandableScreenContent className="bg-primary">
  {/* Full screen content */}
</ExpandableScreenContent>
```

**Props:**

- `className?: string` - Additional CSS classes
- `showCloseButton?: boolean` - Whether to show the close button (default: true)
- `closeButtonClassName?: string` - Additional CSS classes for the close button

### ExpandableScreenBackground

An optional component for rendering different content based on the expanded state. Useful for background elements.

```typescriptreact
<ExpandableScreenBackground
  trigger={<div>Trigger Background</div>}
  content={<div>Expanded Background</div>}
/>
```

**Props:**

- `trigger?: ReactNode` - Content to show when collapsed
- `content?: ReactNode` - Content to show when expanded
- `className?: string` - Additional CSS classes

### useExpandableScreen Hook

Hook to access the expandable screen context and control the expanded state.

```typescriptreact
function CustomComponent() {
  const { isExpanded, expand, collapse, layoutId } = useExpandableScreen();

  return (
    <div>
      <p>Is expanded: {isExpanded ? "Yes" : "No"}</p>
      <button onClick={expand}>Expand</button>
      <button onClick={collapse}>Collapse</button>
    </div>
  );
}
```

**Returns:**

- `isExpanded: boolean` - Whether the screen is currently expanded
- `expand: () => void` - Function to expand the screen
- `collapse: () => void` - Function to collapse the screen
- `layoutId: string` - The shared layout ID for morphing animations
- `triggerRadius: string` - Border radius of the trigger
- `contentRadius: string` - Border radius of the content
- `animationDuration: number` - Animation duration

## Advanced Usage

### Custom Layout IDs

Use unique `layoutId` values when you have multiple expandable screens on the same page:

```typescriptreact
<ExpandableScreen layoutId="waitlist-form">
  {/* Content */}
</ExpandableScreen>

<ExpandableScreen layoutId="contact-form">
  {/* Different content */}
</ExpandableScreen>
```

### Controlled State

Control the expanded state externally:

```typescriptreact
const [isExpanded, setIsExpanded] = useState(false)

<ExpandableScreen
  layoutId="controlled-screen"
  defaultExpanded={isExpanded}
  onExpandChange={setIsExpanded}
>
  {/* Content */}
</ExpandableScreen>
```

### Custom Close Button

Customize or hide the close button:

```typescriptreact
<ExpandableScreenContent showCloseButton={false} className="bg-primary">
  <div className="flex h-full items-center justify-center">
    <button onClick={() => collapse()}>Custom Close</button>
  </div>
</ExpandableScreenContent>
```

### Different Border Radius

Create different visual styles by adjusting the border radius:

```typescriptreact
<ExpandableScreen
  layoutId="rounded-card"
  triggerRadius="12px"
  contentRadius="0px"
>
  {/* Content */}
</ExpandableScreen>
```

## How It Works

The ExpandableScreen component uses Framer Motion's `layoutId` feature to create morphing animations. Here's how it works:

1. **Shared Layout ID**: Both the trigger and content share the same `layoutId`, which tells Framer Motion they're the same element in different states.

2. **Layout Animation**: When the state changes, Framer Motion automatically animates the position, size, and shape between the two elements.

3. **Smooth Transitions**: The component uses `layout` animations combined with opacity transitions for smooth, performant animations.

4. **Scroll Locking**: By default, the component locks body scroll when expanded to prevent background scrolling.

## Customization

The ExpandableScreen component is highly customizable:

- Adjust animation duration for faster or slower transitions
- Customize border radius for different visual styles
- Control scroll locking behavior
- Customize close button appearance or hide it entirely
- Use different layout IDs for multiple instances

## Accessibility

The ExpandableScreen component includes:

- Keyboard support (Escape key closes the expanded screen)
- Proper ARIA labels on the close button
- Focus management
- Scroll locking to prevent background interaction when expanded

## Performance

The component is optimized for performance:

- Uses `transform-gpu` and `will-change-transform` for GPU acceleration
- Leverages Framer Motion's layout animations for efficient transitions
- Minimal re-renders with context-based state management
