# Morph Surface

> React component for shadcn/ui that morphs a button into a textarea panel with spring animations, for feedback and quick notes.

Source: https://www.cult-ui.com/docs/components/morph-surface

## Example

```tsx title="morph-surface-demo.tsx"
"use client"

import { useState } from "react"
import { HelpCircle } from "lucide-react"

import { MorphSurface } from "@/components/ui/morph-surface"

export default function MorphSurfaceDemo() {
  const [isControlledOpen, setIsControlledOpen] = useState(false)

  const handleSubmit = async (formData: FormData) => {
    const message = formData.get("message") as string
    console.log("Submitted message:", message)
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 500))
  }

  return (
    <div className="space-y-12 p-8">
      {/* Default Usage */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Default Usage</h2>
        <p className="text-muted-foreground text-sm">
          The component works out of the box with no configuration needed
        </p>
        <div className="flex min-h-[300px] items-center justify-center rounded-lg p-8">
          <MorphSurface />
        </div>
      </section>

      {/* Custom Labels and Content */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Custom Labels & Content</h2>
        <p className="text-muted-foreground text-sm">
          Customize trigger label, placeholder, and submit behavior
        </p>
        <div className="flex min-h-[300px] items-center justify-center rounded-lg p-8">
          <MorphSurface
            triggerLabel="Send Feedback"
            placeholder="Share your thoughts..."
            onSubmit={handleSubmit}
            onSuccess={() => {
              console.log("Feedback submitted successfully!")
            }}
          />
        </div>
      </section>

      {/* Custom Dimensions */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Custom Dimensions</h2>
        <p className="text-muted-foreground text-sm">
          Adjust the size when collapsed and expanded
        </p>
        <div className="flex min-h-[300px] items-center justify-center rounded-lg p-8">
          <MorphSurface
            collapsedWidth="auto"
            collapsedHeight={48}
            expandedWidth={400}
            expandedHeight={250}
            triggerLabel="Custom Size"
            placeholder="This surface is larger..."
          />
        </div>
      </section>

      {/* Custom Trigger Icon */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Custom Trigger Icon</h2>
        <p className="text-muted-foreground text-sm">
          Add an icon to the trigger button
        </p>
        <div className="flex min-h-[300px] items-center justify-center rounded-lg p-8">
          <MorphSurface
            triggerLabel="Help"
            triggerIcon={<HelpCircle className="h-4 w-4" />}
            placeholder="How can we help you?"
            onSubmit={handleSubmit}
          />
        </div>
      </section>

      {/* Controlled State */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Controlled State</h2>
        <p className="text-muted-foreground text-sm">
          Control the open/close state externally
        </p>
        <div className="flex min-h-[300px] flex-col items-center gap-12 rounded-lg p-8">
          <button
            type="button"
            onClick={() => setIsControlledOpen((prev) => !prev)}
            className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 transition-colors"
          >
            {isControlledOpen ? "Close" : "Open"} Morph Surface
          </button>
          <MorphSurface
            isOpen={isControlledOpen}
            onOpenChange={setIsControlledOpen}
            triggerLabel="Controlled"
            placeholder="This surface is controlled externally..."
            onSubmit={handleSubmit}
          />
        </div>
      </section>

      {/* Custom Animation Speed */}
      <section className="space-y-4">
        <h2 className="text-2xl font-semibold">Custom Animation Speed</h2>
        <p className="text-muted-foreground text-sm">
          Speed up or slow down animations (higher values = slower animations)
        </p>
        <div className="flex min-h-[300px] items-center justify-center rounded-lg p-8">
          <MorphSurface
            animationSpeed={2}
            triggerLabel="Slow Animation"
            placeholder="Animations are slower..."
          />
        </div>
      </section>
    </div>
  )
}
```

MorphSurface is a React component for shadcn/ui with controlled or uncontrolled open state. Use it for feedback widgets, quick notes, or inline forms, where Escape closes the panel and Cmd+Enter submits.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/morph-surface
```

### Manual

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

```tsx title="morph-surface.tsx"
"use client"

import React, {
  createContext,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type RefObject,
} from "react"
import { AnimatePresence, motion } from "motion/react"

import { cn } from "@/lib/utils"

type SpringConfig = {
  type: "spring"
  stiffness: number
  damping: number
  mass?: number
  delay?: number
}

const SPEED = 1
const FEEDBACK_WIDTH = 360
const FEEDBACK_HEIGHT = 200

// Props interfaces
interface TriggerProps {
  isOpen: boolean
  onClick: () => void
  className?: string
}

interface ContentProps {
  isOpen: boolean
  onClose: () => void
  onSubmit: (data: FormData) => void | Promise<void>
  className?: string
}

interface IndicatorProps {
  success: boolean
  isOpen: boolean
  className?: string
}

interface MorphSurfaceProps {
  // Dimensions
  collapsedWidth?: number | "auto"
  collapsedHeight?: number
  expandedWidth?: number
  expandedHeight?: number

  // Animation
  animationSpeed?: number
  springConfig?: SpringConfig

  // Content
  triggerLabel?: string
  triggerIcon?: React.ReactNode
  placeholder?: string
  submitLabel?: string

  // Callbacks
  onSubmit?: (data: FormData) => void | Promise<void>
  onOpen?: () => void
  onClose?: () => void
  onSuccess?: () => void

  // Controlled state
  isOpen?: boolean
  onOpenChange?: (open: boolean) => void

  // Styles
  className?: string
  triggerClassName?: string
  contentClassName?: string

  // Render props
  renderTrigger?: (props: TriggerProps) => React.ReactNode
  renderContent?: (props: ContentProps) => React.ReactNode
  renderIndicator?: (props: IndicatorProps) => React.ReactNode
}

interface MorphSurfaceContextValue {
  showFeedback: boolean
  success: boolean
  openFeedback: () => void
  closeFeedback: () => void
  // Configurable props
  triggerLabel: string
  triggerIcon?: React.ReactNode
  placeholder: string
  submitLabel: string
  onSubmit?: (data: FormData) => void | Promise<void>
  onOpen?: () => void
  onClose?: () => void
  onSuccess?: () => void
  triggerClassName?: string
  contentClassName?: string
  renderTrigger?: (props: TriggerProps) => React.ReactNode
  renderContent?: (props: ContentProps) => React.ReactNode
  renderIndicator?: (props: IndicatorProps) => React.ReactNode
  animationSpeed: number
  springConfig?: SpringConfig
  expandedWidth: number
  expandedHeight: number
}

const MorphSurfaceContext = createContext<MorphSurfaceContextValue>(
  {} as MorphSurfaceContextValue
)

const useMorphSurface = () => useContext(MorphSurfaceContext)

// Internal hook logic
function useMorphSurfaceLogic({
  isOpen: controlledIsOpen,
  onOpenChange,
  expandedWidth = FEEDBACK_WIDTH,
  expandedHeight = FEEDBACK_HEIGHT,
  collapsedHeight = 44,
  animationSpeed = SPEED,
}: {
  isOpen?: boolean
  onOpenChange?: (open: boolean) => void
  expandedWidth?: number
  expandedHeight?: number
  collapsedHeight?: number
  animationSpeed?: number
}) {
  const containerRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLTextAreaElement | null>(null)
  const [internalIsOpen, setInternalIsOpen] = useState(false)
  const [success, setSuccess] = useState(false)

  const isOpen =
    controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen
  const showFeedback = isOpen

  function closeFeedback() {
    if (controlledIsOpen !== undefined) {
      onOpenChange?.(false)
    } else {
      setInternalIsOpen(false)
    }
    inputRef.current?.blur()
  }

  function openFeedback() {
    if (controlledIsOpen !== undefined) {
      onOpenChange?.(!isOpen)
    } else {
      setInternalIsOpen((prev) => !prev)
    }
    if (!showFeedback) {
      setTimeout(() => {
        inputRef.current?.focus()
      })
    }
  }

  function setSuccessState(value: boolean) {
    setSuccess(value)
  }

  useClickOutside(containerRef, closeFeedback, showFeedback)

  return {
    containerRef,
    inputRef,
    showFeedback,
    success,
    openFeedback,
    closeFeedback,
    setSuccess: setSuccessState,
    expandedWidth,
    expandedHeight,
    collapsedHeight,
    animationSpeed,
  }
}

// Root component
export function MorphSurface({
  collapsedWidth = FEEDBACK_WIDTH,
  collapsedHeight = 44,
  expandedWidth = FEEDBACK_WIDTH,
  expandedHeight = FEEDBACK_HEIGHT,
  animationSpeed = SPEED,
  springConfig,
  triggerLabel = "Feedback",
  triggerIcon,
  placeholder = "What's on your mind?",
  submitLabel,
  onSubmit,
  onOpen,
  onClose,
  onSuccess,
  isOpen: controlledIsOpen,
  onOpenChange,
  className,
  triggerClassName,
  contentClassName,
  renderTrigger,
  renderContent,
  renderIndicator,
}: MorphSurfaceProps = {}) {
  const hookLogic = useMorphSurfaceLogic({
    isOpen: controlledIsOpen,
    onOpenChange,
    expandedWidth,
    expandedHeight,
    collapsedHeight,
    animationSpeed,
  })

  const {
    containerRef,
    inputRef,
    showFeedback,
    success,
    openFeedback,
    closeFeedback,
    setSuccess,
    expandedWidth: hookExpandedWidth,
    expandedHeight: hookExpandedHeight,
    collapsedHeight: hookCollapsedHeight,
  } = hookLogic

  function onFeedbackSuccess() {
    closeFeedback()
    setSuccess(true)
    setTimeout(() => {
      setSuccess(false)
    }, 1500)
    onSuccess?.()
  }

  const context = useMemo(
    () => ({
      showFeedback,
      success,
      openFeedback: () => {
        openFeedback()
        onOpen?.()
      },
      closeFeedback: () => {
        closeFeedback()
        onClose?.()
      },
      triggerLabel,
      triggerIcon,
      placeholder,
      submitLabel: submitLabel || "⌘ Enter",
      onSubmit,
      onOpen,
      onClose,
      onSuccess,
      triggerClassName,
      contentClassName,
      renderTrigger,
      renderContent,
      renderIndicator,
      animationSpeed,
      springConfig,
      expandedWidth: hookExpandedWidth,
      expandedHeight: hookExpandedHeight,
    }),
    [
      showFeedback,
      success,
      openFeedback,
      closeFeedback,
      triggerLabel,
      triggerIcon,
      placeholder,
      submitLabel,
      onSubmit,
      onOpen,
      onClose,
      onSuccess,
      triggerClassName,
      contentClassName,
      renderTrigger,
      renderContent,
      renderIndicator,
      animationSpeed,
      springConfig,
      hookExpandedWidth,
      hookExpandedHeight,
    ]
  )

  return (
    <div
      className={cn("flex items-end justify-center", className)}
      style={{
        width: hookExpandedWidth,
        height: hookExpandedHeight,
      }}
    >
      <motion.div
        ref={containerRef}
        onClick={() => {
          if (!showFeedback) {
            openFeedback()
          }
        }}
        className={cn(
          "relative bottom-8 z-10 flex flex-col items-center overflow-hidden",
          "bg-card dark:bg-muted",
          "shadow-[0px_1px_1px_0px_rgba(0,_0,_0,_0.05),_0px_1px_1px_0px_rgba(255,_252,_240,_0.5)_inset,_0px_0px_0px_1px_hsla(0,_0%,_100%,_0.1)_inset,_0px_0px_1px_0px_rgba(28,_27,_26,_0.5)]",
          "dark:shadow-[0px_1px_0px_0px_hsla(0,_0%,_0%,_0.02)_inset,_0px_0px_0px_1px_hsla(0,_0%,_0%,_0.02)_inset,_0px_0px_0px_1px_rgba(255,_255,_255,_0.25)]",
          !showFeedback &&
            "cursor-pointer transition-[filter] duration-200 hover:brightness-105"
        )}
        initial={false}
        animate={{
          width: showFeedback ? hookExpandedWidth : collapsedWidth,
          height: showFeedback ? hookExpandedHeight : hookCollapsedHeight,
          borderRadius: showFeedback ? 14 : 20,
        }}
        transition={
          springConfig || {
            type: "spring",
            stiffness: 550 / animationSpeed,
            damping: 45,
            mass: 0.7,
            delay: showFeedback ? 0 : 0.08,
          }
        }
      >
        <MorphSurfaceContext.Provider value={context}>
          <MorphSurfaceDock />
          <MorphSurfaceFeedback ref={inputRef} onSuccess={onFeedbackSuccess} />
        </MorphSurfaceContext.Provider>
      </motion.div>
    </div>
  )
}

// Dock component
function MorphSurfaceDock() {
  const {
    success,
    showFeedback,
    openFeedback,
    triggerLabel,
    triggerIcon,
    triggerClassName,
    renderTrigger,
    renderIndicator,
    animationSpeed,
    springConfig,
  } = useMorphSurface()

  const logoSpring = springConfig || {
    type: "spring" as const,
    stiffness: 350 / animationSpeed,
    damping: 35,
  }

  const checkSpring = {
    type: "spring" as const,
    stiffness: 500 / animationSpeed,
    damping: 22,
  }

  const handleTriggerClick = (e: React.MouseEvent) => {
    e.stopPropagation()
    openFeedback()
  }

  const defaultIndicator = (
    <>
      {showFeedback ? (
        <div className="h-5 w-5" style={{ opacity: 0 }} />
      ) : (
        <motion.div
          className="bg-primary h-5 w-5 rounded-full"
          layoutId={`morph-surface-dot-${triggerLabel}`}
          transition={logoSpring}
        >
          <AnimatePresence>
            {success && (
              <motion.div
                key="check"
                exit={{ opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                initial={{ opacity: 0, scale: 0.5 }}
                transition={{
                  ...checkSpring,
                  delay: success ? 0.3 : 0,
                }}
                className="m-[2px]"
              >
                <IconCheck />
              </motion.div>
            )}
          </AnimatePresence>
        </motion.div>
      )}
    </>
  )

  const defaultTrigger = (
    <button
      type="button"
      className={cn(
        "m-[-8px] flex flex-1 justify-end gap-1 rounded-full p-2",
        "text-muted-foreground hover:text-foreground",
        "transition-colors duration-200",
        "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
        triggerClassName
      )}
      onClick={handleTriggerClick}
    >
      {triggerIcon && <span className="flex items-center">{triggerIcon}</span>}
      <span className="ml-1 max-w-[20ch] truncate">{triggerLabel}</span>
    </button>
  )

  const indicatorElement = renderIndicator
    ? renderIndicator({
        success,
        isOpen: showFeedback,
      })
    : defaultIndicator

  const triggerElement = renderTrigger
    ? renderTrigger({
        isOpen: showFeedback,
        onClick: () => openFeedback(),
        className: triggerClassName,
      })
    : defaultTrigger

  return (
    <footer className="mt-auto flex h-[44px] items-center justify-center whitespace-nowrap select-none">
      <div className="flex items-center justify-center gap-6 px-3">
        <div className="flex w-fit items-center gap-2">
          {indicatorElement}
          <div className="text-foreground text-sm">Morph Surface</div>
        </div>
        {triggerElement}
      </div>
    </footer>
  )
}

// Feedback component
const MorphSurfaceFeedback = React.forwardRef<
  HTMLTextAreaElement,
  { onSuccess: () => void }
>(({ onSuccess }, ref) => {
  const {
    closeFeedback,
    showFeedback,
    placeholder,
    onSubmit,
    contentClassName,
    renderContent,
    expandedWidth,
    expandedHeight,
    animationSpeed,
    triggerLabel,
  } = useMorphSurface()
  const submitRef = React.useRef<HTMLButtonElement>(null)

  const contentSpring = {
    type: "spring" as const,
    stiffness: 550 / animationSpeed,
    damping: 45,
    mass: 0.7,
  }

  const logoSpring = {
    type: "spring" as const,
    stiffness: 350 / animationSpeed,
    damping: 35,
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    if (onSubmit) {
      try {
        await onSubmit(formData)
        onSuccess()
      } catch (error) {
        console.error("Form submission error:", error)
      }
    } else {
      onSuccess()
    }
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === "Escape") {
      closeFeedback()
    }
    if (e.key === "Enter" && e.metaKey) {
      e.preventDefault()
      submitRef.current?.click()
    }
  }

  const defaultContent = (
    <>
      <div className="flex justify-between py-1">
        <p className="text-muted-foreground z-[2] ml-[25px] flex items-center gap-[6px] text-sm select-none">
          {triggerLabel}
        </p>
        <button
          type="submit"
          ref={submitRef}
          className={cn(
            "mt-1 flex -translate-y-[3px] items-center justify-center gap-1 text-sm",
            "text-muted-foreground right-4 bg-transparent text-center select-none",
            "cursor-pointer rounded-xl pr-1",
            "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
          )}
        >
          <Kbd>⌘</Kbd>
          <Kbd className="w-fit">Enter</Kbd>
        </button>
      </div>
      <textarea
        ref={ref}
        placeholder={placeholder}
        name="message"
        className={cn(
          "h-full w-full resize-none scroll-py-2 p-4 text-base outline-none",
          "bg-muted dark:bg-accent rounded-t-xs rounded-b-lg",
          "caret-primary",
          "placeholder:text-muted-foreground",
          "focus-visible:ring-ring/20 focus-visible:ring-1 focus-visible:ring-offset-0"
        )}
        required
        onKeyDown={onKeyDown}
        spellCheck={false}
      />
    </>
  )

  const handleContentSubmit = async (data: FormData) => {
    if (onSubmit) {
      try {
        await onSubmit(data)
        onSuccess()
      } catch (error) {
        console.error("Form submission error:", error)
      }
    } else {
      onSuccess()
    }
  }

  const contentElement = renderContent
    ? renderContent({
        isOpen: showFeedback,
        onClose: closeFeedback,
        onSubmit: handleContentSubmit,
        className: contentClassName,
      })
    : defaultContent

  return (
    <form
      onSubmit={handleSubmit}
      className={cn("absolute bottom-0", contentClassName)}
      style={{
        width: expandedWidth,
        height: expandedHeight,
        pointerEvents: showFeedback ? "all" : "none",
      }}
    >
      <AnimatePresence>
        {showFeedback && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={contentSpring}
            className="flex h-full flex-col p-1"
          >
            {contentElement}
          </motion.div>
        )}
      </AnimatePresence>
      {showFeedback && (
        <motion.div
          layoutId={`morph-surface-dot-${triggerLabel}`}
          className="bg-primary absolute top-[18.5px] left-4 h-2 w-2 rounded-full"
          transition={logoSpring}
        />
      )}
    </form>
  )
})

MorphSurfaceFeedback.displayName = "MorphSurfaceFeedback"

// Utility components
function IconCheck() {
  return (
    <svg
      width="16px"
      height="16px"
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      color="white"
    >
      <title>Icon Check</title>
      <path
        d="M5 13L9 17L19 7"
        stroke="currentColor"
        strokeWidth="2px"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  )
}

function Kbd({
  children,
  className,
}: {
  children: string
  className?: string
}) {
  return (
    <kbd
      className={cn(
        "bg-muted text-muted-foreground flex h-6 w-6 items-center justify-center rounded border px-[6px] font-sans text-xs",
        className
      )}
    >
      {children}
    </kbd>
  )
}

function useClickOutside<T extends HTMLElement = HTMLElement>(
  ref: RefObject<T | null>,
  handler: (event: MouseEvent | TouchEvent | PointerEvent) => void,
  isOpen: boolean
) {
  useEffect(() => {
    let startedOutsideWhileOpen = false

    const isOutside = (event: Event) => {
      const el = ref?.current
      return !!el && !el.contains((event?.target as Node) || null)
    }

    const handlePointerStart = (event: PointerEvent) => {
      startedOutsideWhileOpen = isOpen && isOutside(event)
    }

    const handleClick = (event: MouseEvent) => {
      // Only close if this interaction began outside while the surface was open.
      if (startedOutsideWhileOpen && isOpen && isOutside(event)) {
        handler(event)
      }
      startedOutsideWhileOpen = false
    }

    const handleTouchEnd = (event: TouchEvent) => {
      // Touch interactions may not emit click consistently across browsers.
      if (startedOutsideWhileOpen && isOpen && isOutside(event)) {
        handler(event)
      }
      startedOutsideWhileOpen = false
    }

    document.addEventListener("pointerdown", handlePointerStart)
    document.addEventListener("click", handleClick)
    document.addEventListener("touchend", handleTouchEnd)

    return () => {
      document.removeEventListener("pointerdown", handlePointerStart)
      document.removeEventListener("click", handleClick)
      document.removeEventListener("touchend", handleTouchEnd)
    }
  }, [ref, handler, isOpen])
}
```

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

## Usage

```tsx
import { MorphSurface } from "@/components/ui/morph-surface";
```

### Basic Usage

The component works out of the box with no configuration needed:

```tsx
<MorphSurface />
```

### Custom Labels and Content

Customize the trigger label, placeholder, and submit behavior:

```tsx
<MorphSurface
  triggerLabel="Send Feedback"
  placeholder="Share your thoughts..."
  onSubmit={async (formData) => {
    const message = formData.get("message") as string;
    console.log("Submitted:", message);
    // Handle submission
  }}
  onSuccess={() => {
    console.log("Feedback submitted successfully!");
  }}
/>
```

### Custom Dimensions

Adjust the size when collapsed and expanded:

```tsx
<MorphSurface
  collapsedWidth="auto"
  collapsedHeight={48}
  expandedWidth={400}
  expandedHeight={250}
  triggerLabel="Custom Size"
  placeholder="This surface is larger..."
/>
```

### Custom Trigger Icon

Add an icon to the trigger button:

```tsx
import { HelpCircle } from "lucide-react";

<MorphSurface
  triggerLabel="Help"
  triggerIcon={<HelpCircle className="w-4 h-4" />}
  placeholder="How can we help you?"
/>;
```

### Controlled State

Control the open/close state externally:

```tsx
import { useState } from "react";

function ControlledExample() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"} Morph Surface
      </button>
      <MorphSurface
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        triggerLabel="Controlled"
        placeholder="This surface is controlled externally..."
      />
    </div>
  );
}
```

### Custom Animation Speed

Speed up or slow down animations (higher values = slower animations):

```tsx
// Slow animations (2x slower)
<MorphSurface animationSpeed={2} triggerLabel="Slow Animation" />

// Fast animations (2x faster)
<MorphSurface animationSpeed={0.5} triggerLabel="Fast Animation" />
```

### Custom Styling

Apply custom className props for styling:

```tsx
<MorphSurface
  className="shadow-2xl"
  triggerClassName="hover:bg-primary/10"
  contentClassName="border-2 border-primary/20"
  triggerLabel="Styled"
  placeholder="Custom styling applied..."
/>
```

## API Reference

### MorphSurface Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `collapsedWidth` | `number \| "auto"` | `360` | Width when collapsed |
| `collapsedHeight` | `number` | `44` | Height when collapsed |
| `expandedWidth` | `number` | `360` | Width when expanded |
| `expandedHeight` | `number` | `200` | Height when expanded |
| `animationSpeed` | `number` | `1` | Animation speed divisor (higher = slower, lower = faster) |
| `springConfig` | `SpringConfig` | - | Custom spring animation configuration |
| `triggerLabel` | `string` | `"Feedback"` | Label text for the trigger button |
| `triggerIcon` | `React.ReactNode` | - | Icon to display in the trigger button |
| `placeholder` | `string` | `"What's on your mind?"` | Placeholder text for the textarea input |
| `submitLabel` | `string` | `"⌘ Enter"` | Label for the submit button |
| `onSubmit` | `(data: FormData) => void \| Promise<void>` | - | Callback when form is submitted |
| `onOpen` | `() => void` | - | Callback when surface opens |
| `onClose` | `() => void` | - | Callback when surface closes |
| `onSuccess` | `() => void` | - | Callback after successful submission |
| `isOpen` | `boolean` | - | Controlled state for open/close |
| `onOpenChange` | `(open: boolean) => void` | - | Callback for controlled state changes |
| `className` | `string` | - | Additional CSS classes for the root container |
| `triggerClassName` | `string` | - | Additional CSS classes for the trigger button |
| `contentClassName` | `string` | - | Additional CSS classes for the content area |
| `renderTrigger` | `(props: TriggerProps) => React.ReactNode` | - | Custom render function for the trigger |
| `renderContent` | `(props: ContentProps) => React.ReactNode` | - | Custom render function for the content |
| `renderIndicator` | `(props: IndicatorProps) => React.ReactNode` | - | Custom render function for the indicator dot |

### SpringConfig Type

```tsx
type SpringConfig = {
  type: "spring";
  stiffness: number;
  damping: number;
  mass?: number;
  delay?: number;
};
```

### Render Props Types

```tsx
interface TriggerProps {
  isOpen: boolean;
  onClick: () => void;
  className?: string;
}

interface ContentProps {
  isOpen: boolean;
  onClose: () => void;
  onSubmit: (data: FormData) => void | Promise<void>;
  className?: string;
}

interface IndicatorProps {
  success: boolean;
  isOpen: boolean;
  className?: string;
}
```

## Features

- **Smooth Animations**: Uses Framer Motion for fluid morphing transitions
- **Customizable Dimensions**: Configure width and height for both collapsed and expanded states
- **Form Handling**: Built-in form submission with FormData extraction
- **Controlled & Uncontrolled**: Support for both controlled and uncontrolled state management
- **Customizable Content**: Customize labels, placeholders, icons, and styling
- **Render Props**: Full control over trigger, content, and indicator rendering
- **Keyboard Shortcuts**: Built-in support for ⌘ Enter (submit) and Escape (close)
- **Success States**: Visual feedback with animated checkmark indicator
- **Accessibility**: Proper focus management and keyboard navigation
- **Click Outside**: Automatically closes when clicking outside the component

## Examples

### Feedback Form

Use as a feedback collection component:

```tsx
<MorphSurface
  triggerLabel="Send Feedback"
  placeholder="What can we improve?"
  onSubmit={async (formData) => {
    const message = formData.get("message") as string;
    await fetch("/api/feedback", {
      method: "POST",
      body: JSON.stringify({ message }),
    });
  }}
  onSuccess={() => {
    toast.success("Thank you for your feedback!");
  }}
/>
```

### Help/Support Widget

Use as a help widget:

```tsx
import { HelpCircle } from "lucide-react";

<MorphSurface
  triggerLabel="Need Help?"
  triggerIcon={<HelpCircle className="w-4 h-4" />}
  placeholder="How can we assist you?"
  onSubmit={async (formData) => {
    // Handle help request
  }}
/>;
```

### Custom Dimensions

Create a larger feedback surface:

```tsx
<MorphSurface
  collapsedWidth="auto"
  collapsedHeight={56}
  expandedWidth={500}
  expandedHeight={300}
  triggerLabel="Extended Feedback"
  placeholder="Share your detailed thoughts..."
/>
```

### With Custom Spring Config

Customize animation spring physics:

```tsx
<MorphSurface
  springConfig={{
    type: "spring",
    stiffness: 400,
    damping: 30,
    mass: 0.5,
  }}
  triggerLabel="Custom Animation"
/>
```

### Controlled State Example

Control the component state externally:

```tsx
import { useState } from "react";

function ControlledMorphSurface() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="space-y-4">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
      >
        {isOpen ? "Close" : "Open"} Feedback
      </button>
      <MorphSurface
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        triggerLabel="Feedback"
        placeholder="Controlled state example..."
      />
    </div>
  );
}
```

### Custom Render Props

Use render props for maximum customization:

```tsx
<MorphSurface
  renderTrigger={({ isOpen, onClick, className }) => (
    <button onClick={onClick} className={cn("custom-trigger", className)}>
      {isOpen ? "Close" : "Open"}
    </button>
  )}
  renderContent={({ isOpen, onClose, onSubmit, className }) => (
    <div className={cn("custom-content", className)}>
      <textarea placeholder="Custom content..." />
      <button onClick={() => onSubmit(new FormData())}>Submit</button>
    </div>
  )}
/>
```

### Async Form Submission

Handle async form submissions:

```tsx
<MorphSurface
  triggerLabel="Submit Issue"
  placeholder="Describe the issue..."
  onSubmit={async (formData) => {
    const message = formData.get("message") as string;

    try {
      const response = await fetch("/api/issues", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message }),
      });

      if (!response.ok) {
        throw new Error("Failed to submit");
      }

      // Success handling is automatic
    } catch (error) {
      console.error("Submission error:", error);
      throw error; // Re-throw to prevent success state
    }
  }}
  onSuccess={() => {
    toast.success("Issue submitted successfully!");
  }}
/>
```

### Multiple Instances

Use multiple instances with different configurations:

```tsx
<div className="flex gap-4">
  <MorphSurface triggerLabel="Feedback" placeholder="General feedback..." />
  <MorphSurface
    triggerLabel="Bug Report"
    placeholder="Report a bug..."
    expandedWidth={400}
  />
  <MorphSurface
    triggerLabel="Feature Request"
    placeholder="Suggest a feature..."
    animationSpeed={0.8}
  />
</div>
```

## Keyboard Shortcuts

- **⌘ Enter** (Mac) / **Ctrl Enter** (Windows): Submit the form
- **Escape**: Close the surface

## Notes

- The component automatically focuses the textarea when opened
- Click outside the component to close it
- Form submission extracts FormData from the form element
- Success indicator shows for 1.5 seconds after successful submission
- The component uses layoutId animations for smooth morphing transitions
- Works seamlessly in both light and dark modes

## Inspiration

This component is inspired by the [Morph Surface](https://devouringdetails.com/) prototype from [Devouring Details](https://devouringdetails.com/), an interactive reference manual for interaction design by Rauno Freiberg. Devouring Details explores interaction design principles through 23 downloadable React components, showcasing the craft of interaction design with attention to detail.
