Mikkel Manniche
Dansk English Deutsch

Widgets

Feedback

Daumen hoch oder runter mit kleinem Funkenregen, danach ein kurzes Formular für einen Kommentar.

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

Vorschau

Verwendung

feedback-demo.tsx
import { Feedback } from '@/components/feedback'

export default function FeedbackDemo() {
  return (
    <div className="flex flex-col items-center gap-4">
      <p className="text-sm font-medium">Did this size guide help?</p>
      <Feedback
        onSubmit={async (data) => {
          await new Promise((r) => setTimeout(r, 700))
          console.log('feedback', data)
        }}
      />
    </div>
  )
}

Quellcode

feedback.tsx
// Based on Watermelon UI's “Feedback” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with lucide icons, focus handling and an async submit.
import { Send, Sparkle, ThumbsDown, ThumbsUp, X } from 'lucide-react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useEffect, useId, useRef, useState } from 'react'
import { cn } from '@/lib/utils'

export type FeedbackRating = 'up' | 'down'

export type FeedbackProps = {
  onSubmit: (data: { rating: FeedbackRating; text: string }) => Promise<void> | void
  title?: string
  upPrompt?: string
  downPrompt?: string
  placeholder?: string
  sendLabel?: string
  sendingLabel?: string
  thanksLabel?: string
  upLabel?: string
  downLabel?: string
  closeLabel?: string
  className?: string
}

/** Thumbs up or down. The thumb pops, then grows into a card that asks for a line more. */
export function Feedback({
  onSubmit,
  title = 'Share feedback',
  upPrompt = 'What did you like most?',
  downPrompt = 'What could be better?',
  placeholder = 'Optional',
  sendLabel = 'Send',
  sendingLabel = 'Sending…',
  thanksLabel = 'Thanks for the feedback',
  upLabel = 'Helpful',
  downLabel = 'Not helpful',
  closeLabel = 'Close',
  className,
}: FeedbackProps) {
  const [rating, setRating] = useState<FeedbackRating | null>(null)
  const [popping, setPopping] = useState<FeedbackRating | null>(null)
  const [open, setOpen] = useState(false)
  const [text, setText] = useState('')
  const [sending, setSending] = useState(false)
  const [thanks, setThanks] = useState(false)
  const field = useRef<HTMLTextAreaElement>(null)
  const thumbs = useRef<HTMLDivElement>(null)
  const id = useId()

  useEffect(() => {
    if (!popping) return
    const t = setTimeout(() => {
      setPopping(null)
      setOpen(true)
    }, 480)
    return () => clearTimeout(t)
  }, [popping])

  useEffect(() => {
    if (open) field.current?.focus()
  }, [open])

  const pick = (r: FeedbackRating) => {
    if (popping) return
    setThanks(false)
    setRating(r)
    setPopping(r)
  }
  const close = () => {
    setOpen(false)
    setText('')
    requestAnimationFrame(() => thumbs.current?.querySelector<HTMLButtonElement>('[aria-pressed=true]')?.focus())
  }

  return (
    <MotionConfig reducedMotion="user" transition={{ duration: 0.3, ease: 'easeInOut' }}>
      <LayoutGroup id={id}>
        <div className={cn('grid min-h-16 place-items-center', className)}>
          <AnimatePresence mode="popLayout" initial={false}>
            {!open ? (
              <motion.div key="thumbs" ref={thumbs} exit={{ opacity: 0, transition: { duration: 0.1 } }} className="flex flex-col items-center gap-2">
                <div className="flex gap-3">
                  {(['up', 'down'] as const).map((r) => {
                    const Icon = r === 'up' ? ThumbsUp : ThumbsDown
                    return (
                      <motion.button
                        key={r}
                        layoutId={rating === r ? `${id}-card` : undefined}
                        type="button"
                        aria-label={r === 'up' ? upLabel : downLabel}
                        aria-pressed={rating === r}
                        onClick={() => pick(r)}
                        whileTap={{ scale: 0.94 }}
                        style={{ borderRadius: 20 }}
                        className="relative grid size-14 place-items-center bg-foreground text-background shadow-lg"
                      >
                        {popping === r &&
                          [0, 1, 2, 3, 4, 5].map((i) => {
                            const a = (i * Math.PI) / 3
                            return (
                              <motion.span
                                key={i}
                                aria-hidden
                                className="pointer-events-none absolute text-foreground"
                                initial={{ scale: 0, x: 0, y: 0, opacity: 1 }}
                                animate={{ scale: [0, 1.2, 0], x: Math.cos(a) * 44, y: Math.sin(a) * 44, opacity: [1, 1, 0], rotate: 90 }}
                                transition={{ duration: 0.48, ease: 'easeOut' }}
                              >
                                <Sparkle className="size-3" fill="currentColor" />
                              </motion.span>
                            )
                          })}
                        <motion.span
                          animate={popping === r ? { scale: [1, 1.7, 1], rotate: [0, r === 'up' ? -30 : 30, 0], y: [0, -4, 0] } : { scale: 1 }}
                          transition={{ duration: 0.48, ease: 'easeOut' }}
                        >
                          <Icon className="size-6" fill={rating === r ? 'currentColor' : 'none'} aria-hidden />
                        </motion.span>
                      </motion.button>
                    )
                  })}
                </div>
                <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
                  {thanks && thanksLabel}
                </p>
              </motion.div>
            ) : (
              <motion.form
                key="card"
                layoutId={`${id}-card`}
                style={{ borderRadius: 28 }}
                onKeyDown={(e) => e.key === 'Escape' && close()}
                onSubmit={async (e) => {
                  e.preventDefault()
                  setSending(true)
                  try {
                    await onSubmit({ rating: rating!, text: text.trim() })
                    setThanks(true)
                    close()
                  } finally {
                    setSending(false)
                  }
                }}
                aria-labelledby={`${id}-title`}
                className="relative w-full max-w-sm overflow-hidden border bg-card p-6 text-card-foreground shadow-2xl"
              >
                <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.12 }}>
                  <button
                    type="button"
                    aria-label={closeLabel}
                    onClick={close}
                    className="absolute top-4 right-4 grid size-8 place-items-center rounded-full bg-muted text-muted-foreground hover:text-foreground"
                  >
                    <X className="size-4" strokeWidth={2.5} aria-hidden />
                  </button>
                  <h2 id={`${id}-title`} className="pr-10 text-xl font-semibold">
                    {title}
                  </h2>
                  <label htmlFor={`${id}-text`} className="mt-1 mb-4 block text-sm text-muted-foreground">
                    {rating === 'up' ? upPrompt : downPrompt}
                  </label>
                  <textarea
                    ref={field}
                    id={`${id}-text`}
                    rows={4}
                    value={text}
                    onChange={(e) => setText(e.target.value)}
                    placeholder={placeholder}
                    className="w-full resize-none rounded-2xl border bg-muted p-3.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  />
                  <button
                    type="submit"
                    disabled={sending}
                    className="mt-3 inline-flex min-h-11 items-center gap-2 rounded-xl bg-foreground px-5 font-medium text-background active:scale-95 disabled:opacity-60"
                  >
                    <Send className="size-4" aria-hidden />
                    {sending ? sendingLabel : sendLabel}
                  </button>
                </motion.div>
              </motion.form>
            )}
          </AnimatePresence>
        </div>
      </LayoutGroup>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/feedback.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/feedback

Dateien

  • feedback.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.