All patterns

Review Submit

Star fills bloom from the middle, then the form folds away and the posted review rises into its place.

commercefriendlypremiuminteraction · finite · intermediate · ~0.7s
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.

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

/**
 * Vibary · Review Submit
 *
 * Rating fills bloom from the middle of each star; submitting folds the
 * form away upward and the posted review rises into the space it left.
 * The two states share one fixed frame, so nothing around the widget
 * reflows and no glyph is ever scaled by a container resizing.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `productName`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ReviewStarsSubmitProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Item being reviewed. */
  productName?: string;
  /** Rating selected before the reader touches anything. */
  defaultRating?: number;
  /** Fires with the posted rating and comment. */
  onSubmit?: (rating: number, comment: string) => void;
};

type VariantConfig = {
  /** Spring for a star fill blooming in. */
  star: { type: "spring"; stiffness: number; damping: number };
  /** Spring for the swap between form and posted card. */
  swap: { type: "spring"; stiffness: number; damping: number };
  /** How far each state travels through the swap, in pixels. */
  travel: number;
  /** Seconds between form rows leaving. */
  stagger: number;
};

// Quality rule: the star fill is the only thing here allowed to scale,
// because it is a glyph drawn as vector art rather than type. Every
// spring is at or above a 0.8 damping ratio — a rating control that
// wobbles undermines the seriousness of the thing being rated.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and flat. For a review prompt embedded in a long order page.
  subtle: {
    star: { type: "spring", stiffness: 620, damping: 44 },
    swap: { type: "spring", stiffness: 520, damping: 42 },
    travel: 10,
    stagger: 0.02,
  },
  // A single soft settle on the fill and on the swap. All-purpose.
  default: {
    star: { type: "spring", stiffness: 460, damping: 36 },
    swap: { type: "spring", stiffness: 400, damping: 34 },
    travel: 18,
    stagger: 0.035,
  },
  // More travel through the fold, for a dedicated review screen.
  playful: {
    star: { type: "spring", stiffness: 420, damping: 33 },
    swap: { type: "spring", stiffness: 340, damping: 31 },
    travel: 26,
    stagger: 0.05,
  },
};

const STAR_GOLD = "#E0A32E";
const ACCENT = "#7C7CF0";

/** 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 that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const STAR_PATH =
  "M10 1.6 12.6 7 18.5 7.9 14.2 12 15.3 17.9 10 15.1 4.7 17.9 5.8 12 1.5 7.9 7.4 7z";

const SCORE_WORDS = ["", "Poor", "Fair", "Good", "Great", "Excellent"];

function Stars({
  rating,
  onRate,
  size,
  spring,
  reduceMotion,
}: {
  rating: number;
  onRate?: (value: number) => void;
  size: number;
  spring: VariantConfig["star"];
  reduceMotion: boolean | null;
}) {
  const interactive = typeof onRate === "function";
  return (
    <div style={{ display: "flex", gap: 4 }} role={interactive ? "radiogroup" : undefined}>
      {[1, 2, 3, 4, 5].map((value) => {
        const filled = value <= rating;
        const glyph = (
          <span
            style={{
              position: "relative",
              display: "block",
              width: size,
              height: size,
            }}
          >
            <svg
              width={size}
              height={size}
              viewBox="0 0 20 20"
              fill="none"
              stroke={tone(30)}
              strokeWidth="1.4"
              strokeLinejoin="round"
              aria-hidden
              style={{ display: "block" }}
            >
              <path d={STAR_PATH} />
            </svg>
            {/* The fill blooms out of the middle of the outline rather
                than swapping colour — the outline never moves, so the
                row keeps its rhythm while the score changes. */}
            <motion.svg
              width={size}
              height={size}
              viewBox="0 0 20 20"
              fill={STAR_GOLD}
              aria-hidden
              initial={false}
              animate={{
                scale: filled ? 1 : 0.35,
                opacity: filled ? 1 : 0,
              }}
              transition={
                reduceMotion ? { duration: 0.14, ease: "easeOut" } : spring
              }
              style={{ position: "absolute", inset: 0, display: "block" }}
            >
              <path d={STAR_PATH} />
            </motion.svg>
          </span>
        );

        if (!interactive) return <span key={value}>{glyph}</span>;

        return (
          <button
            key={value}
            type="button"
            role="radio"
            aria-checked={value === rating}
            aria-label={`${value} star${value === 1 ? "" : "s"}`}
            onClick={() => onRate?.(value)}
            style={{
              display: "block",
              padding: 0,
              border: "none",
              background: "transparent",
              color: "inherit",
              cursor: "pointer",
              lineHeight: 0,
            }}
          >
            {glyph}
          </button>
        );
      })}
    </div>
  );
}

export default function ReviewStarsSubmit({
  variant = "default",
  productName = "Cold brew carafe, 1L",
  defaultRating = 0,
  onSubmit,
}: ReviewStarsSubmitProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [rating, setRating] = useState(defaultRating);
  const [comment, setComment] = useState(
    "Lid seals properly and the glass has survived a month of daily use."
  );
  const [posted, setPosted] = useState(false);

  // Reduced motion: the fold becomes a crossfade. Same two states, same
  // order, none of the travel.
  const enter = reduceMotion
    ? { initial: { opacity: 0 }, animate: { opacity: 1 } }
    : {
        initial: { opacity: 0, y: cfg.travel },
        animate: { opacity: 1, y: 0 },
      };
  const swapTransition = reduceMotion
    ? { duration: 0.18, ease: "easeOut" as const }
    : cfg.swap;

  return (
    <div
      style={{
        width: 342,
        height: 226,
        position: "relative",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        overflow: "hidden",
        fontFamily: "inherit",
      }}
    >
      <AnimatePresence initial={false}>
        {!posted && (
          <motion.form
            key="form"
            onSubmit={(event) => {
              event.preventDefault();
              if (rating === 0) return;
              setPosted(true);
              onSubmit?.(rating, comment);
            }}
            initial={false}
            exit={
              reduceMotion
                ? { opacity: 0, transition: { duration: 0.14 } }
                : {
                    opacity: 0,
                    y: -cfg.travel,
                    transition: { duration: 0.2, ease: "easeIn" },
                  }
            }
            style={{
              position: "absolute",
              inset: 0,
              padding: "15px 18px 16px",
              display: "flex",
              flexDirection: "column",
              gap: 11,
            }}
          >
            {[
              {
                key: "head",
                node: (
                  <>
                    <div style={{ fontSize: 13.5, fontWeight: 650 }}>
                      Rate your purchase
                    </div>
                    <div
                      style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}
                    >
                      {productName}
                    </div>
                  </>
                ),
              },
              {
                key: "stars",
                node: (
                  <div
                    style={{ display: "flex", alignItems: "center", gap: 10 }}
                  >
                    <Stars
                      rating={rating}
                      onRate={setRating}
                      size={24}
                      spring={cfg.star}
                      reduceMotion={reduceMotion}
                    />
                    {/* The word changes by crossfade inside a fixed slot:
                        the stars must not shuffle sideways as the label
                        grows, and the label itself never scales. */}
                    <span
                      style={{
                        position: "relative",
                        display: "block",
                        minWidth: 68,
                        height: 16,
                      }}
                    >
                      <AnimatePresence initial={false}>
                        <motion.span
                          key={SCORE_WORDS[rating] || "none"}
                          initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
                          animate={{ opacity: rating ? 1 : 0.45, y: 0 }}
                          exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
                          transition={{ duration: 0.16, ease: "easeOut" }}
                          style={{
                            position: "absolute",
                            inset: 0,
                            fontSize: 12,
                            fontWeight: 600,
                            lineHeight: "16px",
                            whiteSpace: "nowrap",
                            color: rating ? STAR_GOLD : undefined,
                          }}
                        >
                          {SCORE_WORDS[rating] || "Choose a rating"}
                        </motion.span>
                      </AnimatePresence>
                    </span>
                  </div>
                ),
              },
              {
                key: "comment",
                node: (
                  <textarea
                    value={comment}
                    onChange={(event) => setComment(event.target.value)}
                    rows={2}
                    aria-label="Your review"
                    style={{
                      width: "100%",
                      display: "block",
                      resize: "none",
                      padding: "9px 11px",
                      fontSize: 12,
                      lineHeight: 1.5,
                      fontFamily: "inherit",
                      borderRadius: 11,
                      border: `1px solid ${tone(13)}`,
                      background: tone(5),
                      color: "inherit",
                      boxSizing: "border-box",
                    }}
                  />
                ),
              },
              {
                key: "submit",
                node: (
                  <button
                    type="submit"
                    disabled={rating === 0}
                    style={{
                      width: "100%",
                      padding: "9px 14px",
                      fontSize: 12.5,
                      fontWeight: 650,
                      fontFamily: "inherit",
                      borderRadius: 10,
                      border: "none",
                      background: rating === 0 ? tone(12) : ACCENT,
                      color: rating === 0 ? "inherit" : "#FFFFFF",
                      opacity: rating === 0 ? 0.6 : 1,
                      cursor: rating === 0 ? "not-allowed" : "pointer",
                    }}
                  >
                    Post review
                  </button>
                ),
              },
            ].map((row, index) => (
              // Rows leave in the order they were read, a beat apart, so
              // the form folds away rather than blinking out.
              <motion.div
                key={row.key}
                initial={false}
                exit={
                  reduceMotion
                    ? { opacity: 0, transition: { duration: 0.12 } }
                    : {
                        opacity: 0,
                        y: -8,
                        transition: {
                          duration: 0.18,
                          ease: "easeIn",
                          delay: index * cfg.stagger,
                        },
                      }
                }
                style={row.key === "submit" ? { marginTop: "auto" } : undefined}
              >
                {row.node}
              </motion.div>
            ))}
          </motion.form>
        )}

        {posted && (
          <motion.div
            key="posted"
            {...enter}
            transition={{
              ...swapTransition,
              delay: reduceMotion ? 0.1 : 0.12,
              opacity: {
                duration: 0.22,
                ease: "easeOut",
                delay: reduceMotion ? 0.1 : 0.12,
              },
            }}
            style={{
              position: "absolute",
              inset: 0,
              padding: "15px 18px 16px",
              display: "flex",
              flexDirection: "column",
              gap: 10,
            }}
          >
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 30,
                  height: 30,
                  borderRadius: 999,
                  fontSize: 11.5,
                  fontWeight: 700,
                  color: "#FFFFFF",
                  background: `linear-gradient(140deg, ${ACCENT}, #4F8BF0)`,
                }}
              >
                AR
              </span>
              <span style={{ flex: 1, minWidth: 0 }}>
                <span
                  style={{ display: "block", fontSize: 12.5, fontWeight: 650 }}
                >
                  Your review
                </span>
                <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                  Posted just now · verified purchase
                </span>
              </span>
              <Stars
                rating={rating}
                size={14}
                spring={cfg.star}
                reduceMotion={reduceMotion}
              />
            </div>

            <p
              style={{
                margin: 0,
                padding: "10px 12px",
                fontSize: 12,
                lineHeight: 1.55,
                borderRadius: 12,
                background: tone(5),
                border: `1px solid ${tone(11)}`,
                opacity: 0.8,
              }}
            >
              {comment}
            </p>

            <button
              type="button"
              onClick={() => setPosted(false)}
              style={{
                marginTop: "auto",
                alignSelf: "flex-start",
                padding: "7px 12px",
                fontSize: 12,
                fontWeight: 600,
                fontFamily: "inherit",
                borderRadius: 9,
                border: `1px solid ${tone(14)}`,
                background: tone(7),
                color: "inherit",
                cursor: "pointer",
              }}
            >
              Edit review
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

Rating and posting treated as one moment. Each star keeps a static outline while a filled copy blooms out of its centre, so the row holds its rhythm as the score changes and the score word crossfades in a fixed slot rather than shoving the stars sideways. On submit the form's rows leave upward in the order they were read and the posted card rises into the space. Both states share one fixed frame, so nothing around the widget reflows and no glyph is scaled by a container resizing.

Post-delivery rating promptProduct review formSeller feedbackService rating card

Where it shows up

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

  • StoreDevicesAudioAccessoriesSupport
    NewAster Studio 14From $1,299
    Finish — Slate
    Buy
    Product page

    Stay ratings fill from the centre of each star as you drag across the row.

Related patterns