Mikkel Manniche
Dansk English Deutsch

Bedienelemente

Morphing button

Ein Button, der zu einem E-Mail-Feld wächst und bestätigt, wenn das Formular gesendet ist.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

morphing-button-demo.tsx
import { MorphingButton } from '@/components/morphing-button'

export default function MorphingButtonDemo() {
  return (
    <div className="flex flex-col items-center gap-4 text-center">
      <div>
        <p className="font-medium">Linen shirt, size M</p>
        <p className="text-sm text-muted-foreground">Back in stock next week.</p>
      </div>
      <MorphingButton onSubmit={(email) => console.log('notify', email)} />
    </div>
  )
}

Quellcode

morphing-button.tsx
// Based on Watermelon UI's “Morphing button” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as a real form with email validation, Esc and a thank-you state.
import { Bell, Check } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'

export type MorphingButtonProps = {
  label?: string
  placeholder?: string
  submitLabel?: string
  doneLabel?: string
  onSubmit: (email: string) => void
  className?: string
}

/** A “Notify me” button that opens into an email field, then thanks the person and closes again. */
export function MorphingButton({
  label = 'Notify me',
  placeholder = 'Your email',
  submitLabel = 'Notify me',
  doneLabel = 'You are on the list',
  onSubmit,
  className,
}: MorphingButtonProps) {
  const [state, setState] = useState<'closed' | 'open' | 'done'>('closed')
  const [email, setEmail] = useState('')
  const input = useRef<HTMLInputElement>(null)
  const opener = useRef<HTMLButtonElement>(null)
  const box = useRef<HTMLFormElement>(null)

  useEffect(() => {
    if (state === 'open') input.current?.focus()
    if (state !== 'done') return
    const t = setTimeout(() => setState('closed'), 2200)
    return () => clearTimeout(t)
  }, [state])

  // Clicking anywhere else closes an empty field.
  useEffect(() => {
    if (state !== 'open') return
    const away = (e: PointerEvent) => !box.current?.contains(e.target as Node) && !email && setState('closed')
    document.addEventListener('pointerdown', away)
    return () => document.removeEventListener('pointerdown', away)
  }, [state, email])

  const close = () => {
    setState('closed')
    requestAnimationFrame(() => opener.current?.focus())
  }

  const open = state === 'open'

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 240, damping: 20, mass: 1 }}>
      <motion.form
        ref={box}
        layout
        onSubmit={(e) => {
          e.preventDefault()
          if (!open) return setState('open')
          onSubmit(email.trim())
          setEmail('')
          setState('done')
        }}
        onKeyDown={(e) => e.key === 'Escape' && open && close()}
        style={{ borderRadius: 999 }}
        className={cn('flex items-center overflow-hidden border bg-muted', open ? 'w-full max-w-sm p-1' : 'p-0', className)}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {open && (
            <motion.label key="field" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0 }} className="min-w-0 flex-1 px-4">
              <span className="sr-only">{placeholder}</span>
              <input
                ref={input}
                type="email"
                required
                autoComplete="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                placeholder={placeholder}
                className="w-full bg-transparent font-medium outline-none placeholder:text-muted-foreground"
              />
            </motion.label>
          )}
        </AnimatePresence>
        <motion.button
          ref={opener}
          layout
          type={open ? 'submit' : 'button'}
          onClick={() => !open && state !== 'done' && setState('open')}
          aria-expanded={open}
          className={cn(
            'flex min-h-11 items-center justify-center gap-2 rounded-full font-medium whitespace-nowrap transition-colors duration-200',
            open ? 'bg-card px-5 shadow-sm' : 'px-6 hover:bg-foreground/5',
          )}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            {!open && (
              <motion.span
                key={state}
                layout
                initial={{ opacity: 0, scale: 0, filter: 'blur(4px)' }}
                animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
                exit={{ opacity: 0, scale: 0, filter: 'blur(4px)' }}
              >
                {state === 'done' ? <Check className="size-5 text-success" aria-hidden /> : <Bell className="size-5" aria-hidden />}
              </motion.span>
            )}
          </AnimatePresence>
          <motion.span layout="position" aria-live="polite">
            {state === 'done' ? doneLabel : open ? submitLabel : label}
          </motion.span>
        </motion.button>
      </motion.form>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/morphing-button.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/morphing-button

Dateien

  • morphing-button.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.