Baktiyar Ablaikhan

Peek rating

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Hover the stars to preview a rating on the little face; click to keep it.

Code

"use client";

import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Surface } from "../ui";

const LABELS = ["Awful", "Meh", "Okay", "Good", "Loved it"];
// mouth curve and eye openness for each score
const FACES = [
  { mouth: "M8 17 Q12 13 16 17", eye: 0.5, bg: "#f6d0c9" },
  { mouth: "M8 16.5 Q12 14.5 16 16.5", eye: 0.7, bg: "#f6e1c9" },
  { mouth: "M8 16 L16 16", eye: 0.85, bg: "#f1ecd2" },
  { mouth: "M8 15 Q12 18 16 15", eye: 1, bg: "#dcefd2" },
  { mouth: "M7.5 14.5 Q12 20 16.5 14.5", eye: 1, bg: "#cfe3f7" },
];

function Star({ fill }: { fill: boolean }) {
  return (
    <svg viewBox="0 0 24 24" className="h-full w-full">
      <path
        d="m12 2.8 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 16.8l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z"
        fill={fill ? "#151515" : "#e4e3de"}
        stroke={fill ? "#151515" : "#d6d5cf"}
        strokeWidth={1}
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function PeekRating() {
  const [hover, setHover] = useState<number | null>(null);
  const [score, setScore] = useState<number | null>(null);
  const shown = hover ?? score;
  const face = FACES[shown ?? 2];

  return (
    <Surface>
      <div className="flex flex-col items-center gap-[1em]">
        <motion.div
          animate={{ backgroundColor: face.bg, y: shown === null ? 0 : -4, rotate: shown === null ? 0 : (shown - 2) * 4 }}
          transition={{ type: "spring", stiffness: 300, damping: 18 }}
          className="grid h-[4.6em] w-[4.6em] place-items-center rounded-full"
        >
          <svg viewBox="0 0 24 24" className="h-[3.4em] w-[3.4em]" fill="none" stroke="#151515" strokeWidth={1.5} strokeLinecap="round">
            <motion.ellipse cx="8.5" cy="10" rx="1.1" animate={{ ry: 1.3 * face.eye }} fill="#151515" stroke="none" />
            <motion.ellipse cx="15.5" cy="10" rx="1.1" animate={{ ry: 1.3 * face.eye }} fill="#151515" stroke="none" />
            <motion.path animate={{ d: face.mouth }} transition={{ type: "spring", stiffness: 300, damping: 20 }} />
          </svg>
        </motion.div>
        <div className="flex gap-[0.3em]" onPointerLeave={() => setHover(null)}>
          {LABELS.map((l, i) => (
            <motion.button
              key={l}
              type="button"
              aria-label={l}
              onPointerEnter={() => setHover(i)}
              onClick={() => setScore(i)}
              whileTap={{ scale: 0.8 }}
              animate={{ scale: hover === i ? 1.2 : 1, y: hover === i ? -3 : 0 }}
              transition={{ type: "spring", stiffness: 500, damping: 20 }}
              className="h-[2.2em] w-[2.2em] p-[0.15em]"
            >
              <Star fill={shown !== null && i <= shown} />
            </motion.button>
          ))}
        </div>
        <div className="relative h-[1.4em] w-[10em] overflow-hidden text-center">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={shown ?? -1}
              initial={{ y: 12, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -12, opacity: 0 }}
              className={`text-[0.85em] ${shown === null ? "font-mono text-[0.75em] text-[#8a8984]" : "font-medium"}`}
            >
              {shown === null ? "how was it?" : LABELS[shown]}
              {score !== null && hover === null ? " · saved" : ""}
            </motion.p>
          </AnimatePresence>
        </div>
      </div>
    </Surface>
  );
}
Prompt

Build "Peek rating", a input animation. What it does: Hover the stars to preview a rating on the little face; click to keep it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).