All patterns

Answer Rating Flip

The chosen thumb turns onto its filled face and the row hands over to a short thank-you.

aifriendlysubtleinteraction · finite · starter · ~0.6s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

268 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Answer Rating Flip
 *
 * Rating an answer: the chosen thumb turns over onto its filled face and
 * settles, the option not taken steps aside, and the row hands itself
 * over to a short acknowledgement.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so it reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `accent`, `question`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AnswerRatingFlipProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Prompt shown before a rating is given. */
  question?: string;
  /** Shown once a rating has been given. */
  acknowledgement?: string;
  /** Fill for the chosen thumb. */
  accent?: string;
  /** Fires with the rating the reader gave. */
  onRate?: (rating: "up" | "down") => void;
};

type VariantConfig = {
  /** ms the flip is allowed before the row hands over. */
  handoverMs: number;
  /** px the acknowledgement travels in from. */
  travel: number;
  flipSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the thumb is an icon, so it may turn; the copy is text,
// so it only fades and slides. The flip spring sits above a 0.8 damping
// ratio — a thumb that rocks past its filled face twice turns a one-bit
// answer into a performance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A quick half-turn and an immediate handover. For a rating row that
  // appears under every message in a long thread.
  subtle: {
    handoverMs: 220,
    travel: 4,
    flipSpring: { type: "spring", stiffness: 560, damping: 44 },
  },
  // The turn is legible and the acknowledgement follows it. ζ ≈ 0.89 —
  // the all-purpose setting.
  default: {
    handoverMs: 320,
    travel: 7,
    flipSpring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A slower turn with one soft settle, for a single answer the product
  // genuinely wants feedback on.
  playful: {
    handoverMs: 430,
    travel: 11,
    flipSpring: { type: "spring", stiffness: 380, damping: 32 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const THUMB_PATH =
  "M5.6 14.1V6.9l3-4.5c.25-.38.72-.55 1.15-.4.62.2.98.85.85 1.5l-.42 2.2h3.06c.92 0 1.6.83 1.43 1.73l-.85 4.4c-.13.7-.75 1.2-1.46 1.2H5.6Z";
const THUMB_CUFF = "M5.6 6.9H3.4c-.6 0-1.1.5-1.1 1.1v4.9c0 .6.5 1.1 1.1 1.1h2.2";

function Thumb({ down, filled }: { down?: boolean; filled?: boolean }) {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 17 16"
      fill="none"
      aria-hidden
      style={{ transform: down ? "rotate(180deg)" : undefined, display: "block" }}
    >
      <path
        d={THUMB_PATH}
        fill={filled ? "currentColor" : "none"}
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinejoin="round"
      />
      <path
        d={THUMB_CUFF}
        fill={filled ? "currentColor" : "none"}
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function AnswerRatingFlip({
  variant = "default",
  question = "Was this helpful?",
  acknowledgement = "Thanks — that helps.",
  accent = "#5B5BD6",
  onRate,
}: AnswerRatingFlipProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [rating, setRating] = useState<"up" | "down" | null>(null);
  const [acknowledged, setAcknowledged] = useState(false);
  const [ring, setRing] = useState<string | null>(null);

  const rate = (value: "up" | "down") => {
    if (rating) return;
    setRating(value);
    onRate?.(value);
    // The turn is allowed to finish before the row changes what it says,
    // so the two events read as cause and effect rather than as one blur.
    window.setTimeout(
      () => setAcknowledged(true),
      reduceMotion ? 0 : cfg.handoverMs
    );
  };

  const button = (value: "up" | "down", label: string) => {
    const chosen = rating === value;
    const dismissed = rating !== null && !chosen;
    return (
      <motion.button
        type="button"
        onClick={() => rate(value)}
        aria-label={label}
        aria-pressed={chosen}
        disabled={rating !== null}
        onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible") ? value : null)}
        onBlur={() => setRing(null)}
        initial={false}
        // The option not taken steps aside rather than vanishing: it
        // fades and drifts a few pixels toward the chosen one.
        animate={{
          opacity: dismissed ? 0 : 1,
          x: dismissed && !reduceMotion ? (value === "up" ? 6 : -6) : 0,
        }}
        transition={{ duration: reduceMotion ? 0.12 : 0.18, ease: "easeOut" }}
        style={{
          display: "grid",
          placeItems: "center",
          width: 28,
          height: 28,
          padding: 0,
          fontFamily: "inherit",
          color: chosen ? accent : "inherit",
          background: chosen ? tone(8) : "transparent",
          border: `1px solid ${chosen ? tone(14) : tone(11)}`,
          borderRadius: 8,
          cursor: rating ? "default" : "pointer",
          boxShadow: ring === value ? `0 0 0 3px ${tone(20)}` : "none",
          outline: "none",
          perspective: 320,
        }}
      >
        <motion.span
          initial={false}
          // Reduced motion: no turn. The two faces crossfade, which still
          // reports the state change without rotating anything.
          animate={{ rotateY: chosen && !reduceMotion ? 180 : 0 }}
          transition={reduceMotion ? { duration: 0 } : cfg.flipSpring}
          style={{
            position: "relative",
            display: "block",
            width: 15,
            height: 15,
            transformStyle: "preserve-3d",
          }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: reduceMotion && chosen ? 0 : 1 }}
            transition={{ duration: 0.14 }}
            style={{
              position: "absolute",
              inset: 0,
              backfaceVisibility: "hidden",
              opacity: 0.72,
            }}
          >
            <Thumb down={value === "down"} />
          </motion.span>
          <span
            style={{
              position: "absolute",
              inset: 0,
              backfaceVisibility: "hidden",
              // Under reduced motion nothing turns, so the filled face is
              // brought forward by opacity instead of by rotation.
              transform: reduceMotion ? undefined : "rotateY(180deg)",
              opacity: reduceMotion ? (chosen ? 1 : 0) : 1,
            }}
          >
            <Thumb down={value === "down"} filled />
          </span>
        </motion.span>
      </motion.button>
    );
  };

  return (
    <div
      style={{
        width: 300,
        display: "flex",
        alignItems: "center",
        gap: 10,
        minHeight: 30,
        color: "inherit",
      }}
    >
      <AnimatePresence mode="wait" initial={false}>
        {acknowledged ? (
          <motion.span
            key="ack"
            role="status"
            initial={
              reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.travel }
            }
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.22, ease: "easeOut" }}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: 7,
              fontSize: 12.5,
              opacity: 0.7,
            }}
          >
            <span style={{ color: accent, display: "inline-flex" }}>
              <Thumb down={rating === "down"} filled />
            </span>
            {acknowledgement}
          </motion.span>
        ) : (
          <motion.span
            key="ask"
            initial={false}
            exit={{
              opacity: 0,
              y: reduceMotion ? 0 : -cfg.travel,
              transition: { duration: 0.14, ease: "easeIn" },
            }}
            style={{ display: "inline-flex", alignItems: "center", gap: 9 }}
          >
            <span style={{ fontSize: 12.5, opacity: 0.55 }}>{question}</span>
            <span style={{ display: "inline-flex", gap: 6 }}>
              {button("up", "This answer was helpful")}
              {button("down", "This answer was not helpful")}
            </span>
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

Feedback on a generated answer, given in one click and acknowledged without ceremony. The thumb turns a half-rotation onto its filled face on a well-damped spring, the option not taken fades and drifts aside, and only once the turn has landed does the row swap its prompt for a brief acknowledgement — so the two events read as cause and effect instead of one blur. The thumb is an icon and may rotate; the copy is text and only fades and slides. Under reduced motion the faces crossfade and nothing turns.

Rate a generated answerHelpful or not helpfulReply quality signalInline feedback row

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A thumbs row under each reply that fills the chosen icon on click.

Related patterns