React + MotionMikrointeraktionen

Senden mit Papierflieger

Der Flieger holt Schwung, hebt vom schrumpfenden Button ab, wird zum gezeichneten Häkchen und gleitet dann zurück.

Einstellungen

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Send } from "lucide-react";

type SendState = "idle" | "flying" | "sent";

interface PaperPlaneSendProps {
  label?: string;
  sentLabel?: string;
  color?: string;
}

const sendSpring = { type: "spring" as const, stiffness: 520, damping: 34 };
const sendText = {
  initial: { opacity: 0, y: 10, filter: "blur(4px)" },
  animate: { opacity: 1, y: 0, filter: "blur(0px)" },
  exit: { opacity: 0, y: -10, filter: "blur(4px)" },
};

export default function PaperPlaneSend({
  label = "Senden",
  sentLabel = "Gesendet",
  color = "#0a84ff",
}: PaperPlaneSendProps) {
  const [state, setState] = useState<SendState>("idle");
  const [trip, setTrip] = useState(0);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const reduce = useReducedMotion();

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const send = () => {
    if (state !== "idle") return;
    setState("flying");
    timers.current = [
      setTimeout(() => setState("sent"), reduce ? 0 : 560),
      setTimeout(() => {
        setState("idle");
        setTrip((t) => t + 1);
      }, 2400),
    ];
  };

  return (
    <motion.button
      type="button"
      layout
      onClick={send}
      aria-disabled={state !== "idle"}
      whileTap={{ scale: state === "idle" ? 0.95 : 1 }}
      transition={sendSpring}
      className="relative flex h-12 items-center justify-center gap-2 px-6 text-[15px] font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-black/25 focus-visible:ring-offset-2 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black"
      style={{
        backgroundColor: color,
        borderRadius: 999,
        boxShadow: "inset 0 1px 0 rgba(255,255,255,0.28), inset 0 -1px 0 rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.14), 0 10px 24px -10px " + color,
      }}
    >
      <motion.span layout="position" className="relative grid h-[18px] w-[18px] place-items-center">
        <AnimatePresence initial={false}>
          {state === "sent" ? (
            <motion.svg
              key="check"
              viewBox="0 0 24 24"
              className="absolute h-[18px] w-[18px]"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.5}
              strokeLinecap="round"
              strokeLinejoin="round"
              exit={{ opacity: 0, scale: 0.6 }}
              transition={{ duration: 0.15 }}
            >
              <motion.path
                d="M5 12.5l4.5 4.5L19 7.5"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.32, ease: [0.65, 0, 0.35, 1] }}
              />
            </motion.svg>
          ) : (
            <motion.span
              key={"plane-" + trip}
              className="absolute grid place-items-center"
              initial={trip === 0 ? false : { x: -26, y: 12, rotate: 18, opacity: 0 }}
              animate={
                state === "flying" && !reduce
                  ? {
                      x: [0, -4, 110],
                      y: [0, 3, -80],
                      rotate: [0, 14, -10],
                      scale: [1, 0.92, 0.7],
                      opacity: [1, 1, 0],
                      filter: ["blur(0px)", "blur(0px)", "blur(1.5px)"],
                    }
                  : { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
              }
              exit={{ opacity: 0 }}
              transition={
                state === "flying"
                  ? { duration: 0.56, times: [0, 0.28, 1], ease: [0.55, 0, 0.8, 0.2] }
                  : { type: "spring", stiffness: 260, damping: 22 }
              }
            >
              <Send className="h-[18px] w-[18px]" strokeWidth={1.75} />
            </motion.span>
          )}
        </AnimatePresence>
      </motion.span>
      <AnimatePresence mode="popLayout" initial={false}>
        {state !== "flying" && (
          <motion.span
            key={state}
            layout="position"
            className="whitespace-nowrap leading-5"
            initial={sendText.initial}
            animate={sendText.animate}
            exit={sendText.exit}
            transition={sendSpring}
          >
            {state === "sent" ? sentLabel : label}
          </motion.span>
        )}
      </AnimatePresence>
      <span className="sr-only" aria-live="polite">
        {state === "sent" ? sentLabel : ""}
      </span>
    </motion.button>
  );
}

Prompt für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Mikrointeraktionen

Wofür du ihn nutzen kannst

Eine Animation am Senden-Button zeigt, dass die Nachricht raus ist, und das zählt mehr als gedacht: im Kontaktformular, bei der Newsletter-Anmeldung, am Senden-Button eines Chats. Hier holt der Papierflieger Schwung, fliegt vom Button weg, und neben „Gesendet“ zeichnet sich ein Häkchen. Dann gleitet er zurück und der Button ist wieder bereit.

Starte den Flug erst, wenn dein Formular wirklich verschickt wurde, nicht beim Klick, sonst versprichst du etwas, das schiefgehen kann. Der ganze Ablauf dauert gut zwei Sekunden. Halte beide Texte kurz, damit der Button nicht stark in der Breite springt.